diff --git a/.prettierignore b/.prettierignore index bed6f61b695cfdc27160ebd420db06693298dc8e..884a5022f64371b57d1ca74b5e9d2083185f3d00 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,6 @@ pnpm-lock.yaml # next-pwa employee-portal/public/ + +# validation files +**/data/test/ diff --git a/admin-portal/src/lib/components/formFields/file/FileField.tsx b/admin-portal/src/lib/components/formFields/file/FileField.tsx index 6364256d23c7c6e527cf36d3e570dcbc933e04d9..fe5614317110d4c63faf94acb1977c3142077d13 100644 --- a/admin-portal/src/lib/components/formFields/file/FileField.tsx +++ b/admin-portal/src/lib/components/formFields/file/FileField.tsx @@ -24,7 +24,7 @@ import { FileType } from "@/lib/types/FileType"; import { FileInputButton } from "./FileInputButton"; -const HiddenInput = styled("input")({ display: "hidden" }); +const HiddenInput = styled("input")({ display: "none" }); function resolveAcceptedFileTypes( accept: FileType | FileType[] | undefined, diff --git a/admin-portal/src/lib/components/view/service-directory/dataTransfer/ImportContent.tsx b/admin-portal/src/lib/components/view/service-directory/dataTransfer/ImportContent.tsx index c7626360dc8e190c9ef261a17ca6f544bfcc32ab..43431e41c7fab2bd8996d47438cf007cec43cf7d 100644 --- a/admin-portal/src/lib/components/view/service-directory/dataTransfer/ImportContent.tsx +++ b/admin-portal/src/lib/components/view/service-directory/dataTransfer/ImportContent.tsx @@ -4,11 +4,13 @@ */ import { FormPlus } from "@eshg/lib-portal/components/form/FormPlus"; -import { ApiImportRequest } from "@eshg/service-directory-api"; +import { + ApiImportRequest, + ApiImportRequestFromJSON, +} from "@eshg/service-directory-api"; import { Typography } from "@mui/joy"; import { Formik, FormikHelpers } from "formik"; import { Dispatch, SetStateAction, useState } from "react"; -import * as v from "valibot"; import { useAdminApi } from "@/lib/api/clients"; import { SubmitButton } from "@/lib/components/button/SubmitButton"; @@ -17,101 +19,6 @@ import { SubHeader } from "@/lib/components/header/SubHeader"; import { useTranslation } from "@/lib/i18n/client"; import { FileType } from "@/lib/types/FileType"; -const ApiAdminActorTypeSchema = v.picklist([ - "GM", - "FM", - "LSD", - "WEB", - "ZA", - "ZR", -]); - -const ApiAdminCertificateSchema = v.object({ - signatory: v.string(), - signature: v.string(), - value: v.string(), -}); - -const ApiAdminActorMetadataSchema = v.object({ - id: v.string(), - content: v.optional(v.string()), - changedAt: v.pipe( - v.string(), - v.isoDateTime(), - v.transform((value) => new Date(value)), - ), -}); - -const ApiActorSchema = v.object({ - active: v.boolean(), - commonName: v.string(), - currentCertificate: v.optional(ApiAdminCertificateSchema), - id: v.string(), - manualCertificate: v.boolean(), - metadata: v.optional(ApiAdminActorMetadataSchema), - networkId: v.optional(v.string()), - previousCertificate: v.optional(ApiAdminCertificateSchema), - readableName: v.string(), - type: ApiAdminActorTypeSchema, -}); - -const ApiAdminOrgUnitTypeSchema = v.picklist(["GA", "LA", "ZD"]); -const ApiAdminFederalStateSchema = v.picklist([ - "BW", - "BY", - "BE", - "BB", - "HB", - "HH", - "HE", - "MV", - "NI", - "NW", - "RP", - "SL", - "SN", - "ST", - "SH", - "TH", - "DE", -]); - -const ApiOrgUnitSchema = v.object({ - active: v.boolean(), - actors: v.array(ApiActorSchema), - id: v.string(), - readableName: v.string(), - type: ApiAdminOrgUnitTypeSchema, - federalState: ApiAdminFederalStateSchema, -}); - -const ApiAdminActorSelectorSchema = v.object({ - actorName: v.optional(v.string()), - actorType: v.optional(v.string()), - federalState: v.optional(v.string()), - orgUnitName: v.optional(v.string()), - orgUnitType: v.optional(v.string()), -}); - -const ApiAdminRuleSchema = v.object({ - active: v.boolean(), - client: ApiAdminActorSelectorSchema, - description: v.optional(v.string()), - id: v.string(), - server: ApiAdminActorSelectorSchema, -}); - -const ApiImportRequestSchema = v.object({ - orgUnits: v.pipe( - v.array(ApiOrgUnitSchema), - v.transform((arr) => new Set(arr)), - ), - rules: v.pipe( - v.array(ApiAdminRuleSchema), - v.transform((arr) => new Set(arr)), - ), -}); - interface ImportFormData { file: File | null; } @@ -136,24 +43,14 @@ export function ImportContent({ if (values.file) { try { const fileContent = await values.file.text(); - const parsed = v.safeParse( - ApiImportRequestSchema, + const request: ApiImportRequest = ApiImportRequestFromJSON( JSON.parse(fileContent), ); - if (parsed.success) { - setHasValidationError(false); - const request: ApiImportRequest = parsed.output; - await adminApi.postImport(request); - setIsDbEmpty(false); - } else { - setHasValidationError(true); - // eslint-disable-next-line no-console - console.error( - "Parsed data does not match ApiImportRequest type:", - parsed.issues, - ); - } + await adminApi.postImport(request); + setHasValidationError(false); + setIsDbEmpty(false); } catch (error) { + setHasValidationError(true); // eslint-disable-next-line no-console console.error("Fetched error for postImport():", error); } diff --git a/backend/auditlog/build.gradle b/backend/auditlog/build.gradle index 200cc826434390969f4564dfb793ca49c17109f6..30e21fbf5e5fd6e59dad2c92d7dd556a16003450 100644 --- a/backend/auditlog/build.gradle +++ b/backend/auditlog/build.gradle @@ -11,6 +11,7 @@ dependencies { implementation project(':lib-base-client') implementation project(':lib-auditlog') implementation project(':file-commons') + implementation project(':lib-scheduling') implementation 'commons-io:commons-io:latest.release' implementation 'org.bouncycastle:bcprov-jdk18on:latest.release' diff --git a/backend/auditlog/gradle.lockfile b/backend/auditlog/gradle.lockfile index ae8bbefda1f6216f3dab17899d8d25323209df35..e71a9aabbf739d78284e3debf0d260afc7f8acfa 100644 --- a/backend/auditlog/gradle.lockfile +++ b/backend/auditlog/gradle.lockfile @@ -81,6 +81,9 @@ net.bytebuddy:byte-buddy:1.15.11=productionRuntimeClasspath,runtimeClasspath,tes net.datafaker:datafaker:2.4.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.13.0=testCompileClasspath,testRuntimeClasspath net.java.dev.stax-utils:stax-utils:20070216=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-core:6.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.2.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-spring:6.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.logstash.logback:logstash-logback-encoder:8.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.5.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.minidev:json-smart:2.5.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath 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 df2d3d57348898569f91e9fbae30782fcd146d3a..d9c9f3272184eb531e3015fcc7f7c7bf853b3cff 100644 --- a/backend/auditlog/src/main/java/de/eshg/auditlog/AuditLogController.java +++ b/backend/auditlog/src/main/java/de/eshg/auditlog/AuditLogController.java @@ -28,6 +28,7 @@ import de.eshg.base.user.api.GetUsersResponse; import de.eshg.base.user.api.UserDto; import de.eshg.base.user.api.UserFilterParameters; import de.eshg.lib.auditlog.AuditLogger; +import de.eshg.persistence.IntentionalWritingTransaction; import de.eshg.rest.service.error.AlreadyExistsException; import de.eshg.rest.service.error.BadRequestException; import de.eshg.rest.service.error.ErrorCode; @@ -35,16 +36,10 @@ import de.eshg.rest.service.error.ErrorResponse; import de.eshg.rest.service.error.NotFoundException; import de.eshg.rest.service.security.CurrentUserHelper; import jakarta.servlet.ServletRequest; -import java.io.ByteArrayInputStream; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.UncheckedIOException; -import java.nio.channels.Channels; -import java.nio.channels.FileChannel; -import java.nio.channels.FileLock; -import java.nio.channels.OverlappingFileLockException; -import java.nio.channels.ReadableByteChannel; import java.nio.charset.StandardCharsets; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; @@ -148,6 +143,7 @@ public class AuditLogController implements AuditLogApi, AuditLogArchivingApi { @Override @Transactional + @IntentionalWritingTransaction(reason = "Audit logging") public ResponseEntity<Resource> readAuditLogFile( String key, ReadAuditLogFileRequest readAuditLogFileRequest) { UserDto selfUser = userApi.getSelfUser(); @@ -637,18 +633,7 @@ public class AuditLogController implements AuditLogApi, AuditLogArchivingApi { private void encryptAndStoreAuditLog( AddAuditLogFileRequest addAuditLogFileRequest, MultipartFile file, Path targetDirPath) { - Path logOutputPath = getAuditLogFilePath(targetDirPath); - - try (FileChannel outputChannel = - FileChannel.open( - logOutputPath, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); - FileLock outputFileLock = outputChannel.tryLock()) { - - if (outputFileLock == null) { - throwBadRequestExceptionBecauseFileAlreadyExists(addAuditLogFileRequest); - } - log.debug("Successfully locked file {} [{}].", logOutputPath, outputFileLock); - + try { log.info("Encrypting received audit log symmetrically"); EncryptedPayload encryptedPayload = SymmetricEncryption.encrypt(file.getBytes()); @@ -690,12 +675,14 @@ public class AuditLogController implements AuditLogApi, AuditLogArchivingApi { StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + Path logOutputPath = getAuditLogFilePath(targetDirPath); log.info("Storing symmetrically encrypted audit log at {}", logOutputPath); - try (ReadableByteChannel readableByteChannel = - Channels.newChannel(new ByteArrayInputStream(encryptedPayload.cipherText()))) { - outputChannel.transferFrom(readableByteChannel, 0, Long.MAX_VALUE); - } - } catch (OverlappingFileLockException | FileAlreadyExistsException e) { + Files.write( + logOutputPath, + encryptedPayload.cipherText(), + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE); + } catch (FileAlreadyExistsException e) { throwBadRequestExceptionBecauseFileAlreadyExists(addAuditLogFileRequest); } catch (IOException e) { throw new UncheckedIOException("Unable to write received audit log to targetPath", e); diff --git a/backend/auditlog/src/main/java/de/eshg/auditlog/AuditLogServiceHousekeeping.java b/backend/auditlog/src/main/java/de/eshg/auditlog/AuditLogServiceHousekeeping.java index efc74cf144c6cb830bb704f7ac8d84f985590995..e3d29b54f7a85ea189d4136f864e90c10e2d3458 100644 --- a/backend/auditlog/src/main/java/de/eshg/auditlog/AuditLogServiceHousekeeping.java +++ b/backend/auditlog/src/main/java/de/eshg/auditlog/AuditLogServiceHousekeeping.java @@ -18,6 +18,8 @@ import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; +import net.javacrumbs.shedlock.core.LockAssert; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -43,7 +45,11 @@ class AuditLogServiceHousekeeping { } @Scheduled(cron = "${de.eshg.auditlog.housekeeping.schedule:@daily}") + @SchedulerLock( + name = "AuditlogAuditLogServiceHousekeeping", + lockAtMostFor = "${de.eshg.auditlog.housekeeping.lock-at-most-for:23h}") void performHousekeeping() { + LockAssert.assertLocked(); deleteExpiredGrants(); deleteOldAuditlogs(); } diff --git a/backend/auditlog/src/main/java/de/eshg/auditlog/AuditLogServiceHousekeepingConfig.java b/backend/auditlog/src/main/java/de/eshg/auditlog/AuditLogServiceHousekeepingConfig.java index 16df58fd011596390410b75f991a661d59dc7840..f33d8c650580b02c5e7613e38d1cf75bd9418f02 100644 --- a/backend/auditlog/src/main/java/de/eshg/auditlog/AuditLogServiceHousekeepingConfig.java +++ b/backend/auditlog/src/main/java/de/eshg/auditlog/AuditLogServiceHousekeepingConfig.java @@ -8,10 +8,8 @@ package de.eshg.auditlog; import de.eshg.testhelper.ConditionalOnTestHelperEnabled; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; -import org.springframework.scheduling.annotation.EnableScheduling; @Configuration -@EnableScheduling public class AuditLogServiceHousekeepingConfig { @Configuration diff --git a/backend/auditlog/src/main/resources/migrations/0009_add_schedlock.xml b/backend/auditlog/src/main/resources/migrations/0009_add_schedlock.xml new file mode 100644 index 0000000000000000000000000000000000000000..9367d4a978808aa9dfab45646240b4dda9298a04 --- /dev/null +++ b/backend/auditlog/src/main/resources/migrations/0009_add_schedlock.xml @@ -0,0 +1,24 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 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="1737554127854-1"> + <createTable tableName="shedlock"> + <column name="name" type="VARCHAR(64)"> + <constraints nullable="false" primaryKey="true" primaryKeyName="pk_shedlock"/> + </column> + <column name="lock_until" type="TIMESTAMP WITHOUT TIME ZONE"> + <constraints nullable="false"/> + </column> + <column name="locked_at" type="TIMESTAMP WITHOUT TIME ZONE"> + <constraints nullable="false"/> + </column> + <column name="locked_by" type="VARCHAR(255)"> + <constraints nullable="false"/> + </column> + </createTable> + </changeSet> +</databaseChangeLog> diff --git a/backend/auditlog/src/main/resources/migrations/changelog.xml b/backend/auditlog/src/main/resources/migrations/changelog.xml index c1b070c84744a91c9ab5f984447582ec9f5764e5..5c0c9e771f582ca593b03835b775df5faaf963a6 100644 --- a/backend/auditlog/src/main/resources/migrations/changelog.xml +++ b/backend/auditlog/src/main/resources/migrations/changelog.xml @@ -16,5 +16,6 @@ <include file="migrations/0006_add_dental_as_audit_log_source.xml"/> <include file="migrations/0007_add_official_medical_service_audit_log_source.xml"/> <include file="migrations/0008_add_auditlog_entry.xml"/> + <include file="migrations/0009_add_schedlock.xml"/> </databaseChangeLog> diff --git a/backend/auth/build.gradle b/backend/auth/build.gradle index 508c35ace76133179df5dbf56657e3a51653aa35..b74b6b5a8642a92a784d3805cc71f2cbed1e438c 100644 --- a/backend/auth/build.gradle +++ b/backend/auth/build.gradle @@ -15,6 +15,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation project(':lib-security-config') + implementation project(':lib-matrix-client') implementation project(':lib-commons') implementation project(':lib-keycloak') implementation project(':util-commons') @@ -39,9 +40,13 @@ dockerCompose { // auth should only start when Keycloak is up and provisioned by base tasks.named("composeUp").configure { - def baseUp = project(":base").tasks.named("composeUp") - dependsOn baseUp - mustRunAfter baseUp + dependsOn project(":base").tasks.named("composeUp") +} + +evaluationDependsOn(':synapse') + +tasks.named("test") { + dependsOn project(':synapse').tasks.named("composeUp") } dependencyTrack { diff --git a/backend/auth/gradle.lockfile b/backend/auth/gradle.lockfile index ab5aa92201dbd84104352c149f6503685a68aea1..ff7c8c0d2eb8f9cb8b16a7c44aecaf1803992632 100644 --- a/backend/auth/gradle.lockfile +++ b/backend/auth/gradle.lockfile @@ -115,6 +115,7 @@ org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath, org.mockito:mockito-core:5.14.2=testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.14.2=testCompileClasspath,testRuntimeClasspath org.objenesis:objenesis:3.4=testRuntimeClasspath +org.openapitools:jackson-databind-nullable:0.2.6=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath org.ow2.asm:asm-commons:9.7=jacocoAnt org.ow2.asm:asm-tree:9.7=jacocoAnt diff --git a/backend/auth/src/main/java/de/eshg/security/auth/AuthController.java b/backend/auth/src/main/java/de/eshg/security/auth/AuthController.java index 844ffa762a6f2e21b758f1852c5c68cdd6cd9dae..b2c2dece9a06211e82cf72227e60d914afcd94f7 100644 --- a/backend/auth/src/main/java/de/eshg/security/auth/AuthController.java +++ b/backend/auth/src/main/java/de/eshg/security/auth/AuthController.java @@ -5,17 +5,13 @@ package de.eshg.security.auth; -import com.nimbusds.jwt.JWT; -import com.nimbusds.jwt.JWTParser; import de.cronn.commons.lang.StreamUtil; -import de.eshg.lib.keycloak.KeycloakRole; import de.eshg.rest.service.security.config.AbstractPublicSecurityConfiguration; import de.eshg.rest.service.security.config.AnyRole; import de.eshg.rest.service.security.config.Authenticated; import de.eshg.rest.service.security.config.AuthorizationDefinition; import de.eshg.rest.service.security.config.PermitAll; import io.swagger.v3.oas.annotations.Hidden; -import java.text.ParseException; import java.util.Arrays; import java.util.List; import java.util.Objects; @@ -75,7 +71,7 @@ public class AuthController { switch (authorizationDefinition) { case AnyRole anyRole -> { - List<String> keycloakRoleNames = getRoles(accessToken); + List<String> keycloakRoleNames = RolesResolver.getRoles(accessToken); if (!anyRole.intersects(keycloakRoleNames)) { throw new ForbiddenException("Found none of the granted roles"); @@ -145,18 +141,4 @@ public class AuthController { throw new BadRequestException("Unknown HTTP method: '%s'".formatted(httpMethod)); } } - - private static List<String> getRoles(OAuth2AccessToken accessToken) { - try { - JWT jwt = JWTParser.parse(accessToken.getTokenValue()); - List<String> roles = - jwt.getJWTClaimsSet().getStringListClaim(KeycloakRole.CLAIM_NAME).stream() - .sorted() - .toList(); - log.debug("Roles: {}", roles); - return roles; - } catch (ParseException e) { - throw new UnauthorizedException("Failed to parse the JWT token", e); - } - } } diff --git a/backend/auth/src/main/java/de/eshg/security/auth/AuthProperties.java b/backend/auth/src/main/java/de/eshg/security/auth/AuthProperties.java index 88b2ada4819588dd2ffbd7043c069221d97a7b19..16baa65ca8508f74d3a5859fbb67d15cb973b7e8 100644 --- a/backend/auth/src/main/java/de/eshg/security/auth/AuthProperties.java +++ b/backend/auth/src/main/java/de/eshg/security/auth/AuthProperties.java @@ -10,6 +10,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import java.net.URI; +import java.time.Duration; import java.util.List; import java.util.Map; import java.util.Optional; @@ -24,7 +25,8 @@ import org.springframework.validation.annotation.Validated; public record AuthProperties( @NotNull @Valid Auth auth, @NotNull @Valid HavingUrl reverseProxy, - @NotNull @Valid Keycloak keycloak) { + @NotNull @Valid Keycloak keycloak, + @Valid SynapseProperties synapse) { private static final Logger log = LoggerFactory.getLogger(AuthProperties.class); public AuthProperties { @@ -62,11 +64,20 @@ public record AuthProperties( List<String> bundIdUrlPatterns, @Valid UserAgentFilter userAgentFilter) {} - record Keycloak(@NotNull HavingUrl logout) {} + record Keycloak(@NotNull @Valid HavingUrl logout) {} record HavingUrl(@NotNull URI url) {} - record UserAgentFilter(boolean enabled, @NotEmpty Map<String, UserAgentMinimumVersion> allowed) {} + record UserAgentFilter( + boolean enabled, @Valid @NotEmpty Map<String, UserAgentMinimumVersion> allowed) {} record UserAgentMinimumVersion(Pattern userAgentPattern, String minimumVersion) {} + + public record SynapseProperties( + @Valid SynapseInternal internal, + Duration refreshClockSkew, + @NotNull Boolean activeLogoutEnabled) { + + public record SynapseInternal(@NotNull URI url) {} + } } diff --git a/backend/auth/src/main/java/de/eshg/security/auth/AuthServiceSecurityConfig.java b/backend/auth/src/main/java/de/eshg/security/auth/AuthServiceSecurityConfig.java index a1b1f24d9a49a0c0f4431069f96b56ebf49be700..6c16c398e32d04d844b09e77c856cda35a6233db 100644 --- a/backend/auth/src/main/java/de/eshg/security/auth/AuthServiceSecurityConfig.java +++ b/backend/auth/src/main/java/de/eshg/security/auth/AuthServiceSecurityConfig.java @@ -8,8 +8,13 @@ package de.eshg.security.auth; import com.google.common.collect.Iterables; import de.eshg.lib.common.TimeoutConstants; import de.eshg.security.auth.login.LoginMethod; +import de.eshg.security.auth.synapse.SynapseAuthController; +import de.eshg.security.auth.synapse.SynapseLogoutHandler; import java.time.Clock; import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; @@ -40,6 +45,8 @@ import org.springframework.security.web.util.matcher.AnyRequestMatcher; @Configuration public class AuthServiceSecurityConfig { + private static final Logger log = LoggerFactory.getLogger(AuthServiceSecurityConfig.class); + // We explicitly reconfigure the Spring OAuth client endpoints to live under /auth // This makes it easier to configure the proxy_pass in the Nginx reverse proxy. private static final String AUTHORIZATION_ENDPOINT_BASE_URL = "/auth"; @@ -90,6 +97,7 @@ public class AuthServiceSecurityConfig { HttpSecurity http, List<LoginMethod> loginMethods, ReverseProxyAwareSavedRequestAwareAuthenticationSuccessHandler oauthLoginSuccessHandler, + @Autowired(required = false) SynapseLogoutHandler synapseLogoutHandler, ClientRegistrationRepository clientRegistrationRepository, CsrfTokenRepository csrfTokenRepository) throws Exception { @@ -108,6 +116,7 @@ public class AuthServiceSecurityConfig { .authenticated(); auth.requestMatchers(HttpMethod.GET, AuthController.BASE_URL).authenticated(); + auth.requestMatchers(HttpMethod.GET, SynapseAuthController.BASE_URL).authenticated(); auth.anyRequest().denyAll(); }) @@ -127,12 +136,17 @@ public class AuthServiceSecurityConfig { loginMethods, AUTHORIZATION_ENDPOINT_BASE_URL)))) .logout( - logout -> - logout - .logoutUrl(LOGOUT_URL) - .logoutRequestMatcher(LOGOUT_REQUEST_MATCHER) - .logoutSuccessHandler(logoutSuccessHandler(clientRegistrationRepository)) - .addLogoutHandler(new LogoutCsrfTokenCookieClearingLogoutHandler())) + logout -> { + logout + .logoutUrl(LOGOUT_URL) + .logoutRequestMatcher(LOGOUT_REQUEST_MATCHER) + .logoutSuccessHandler(logoutSuccessHandler(clientRegistrationRepository)) + .addLogoutHandler(new LogoutCsrfTokenCookieClearingLogoutHandler()); + if (synapseLogoutHandler != null) { + log.info("Adding logout handler for Synapse"); + logout.addLogoutHandler(synapseLogoutHandler); + } + }) .csrf( csrf -> csrf.csrfTokenRepository(csrfTokenRepository) diff --git a/backend/auth/src/main/java/de/eshg/security/auth/LogoutController.java b/backend/auth/src/main/java/de/eshg/security/auth/LogoutController.java index f9287d5f66b5389d5509aa992ebba7141913301b..c450171ab26276c065e27ad2e3af7a2b628b54d8 100644 --- a/backend/auth/src/main/java/de/eshg/security/auth/LogoutController.java +++ b/backend/auth/src/main/java/de/eshg/security/auth/LogoutController.java @@ -34,7 +34,6 @@ public class LogoutController { private static final Duration CSRF_TOKEN_MAX_AGE = Duration.ofMinutes(10); private final CsrfTokenRepository csrfTokenRepository; - private final AuthProperties authProperties; private final URI keycloakLogoutUrl; public LogoutController( @@ -42,7 +41,6 @@ public class LogoutController { OAuth2ClientProperties auth2ClientProperties, AuthProperties authProperties) { this.csrfTokenRepository = csrfTokenRepository; - this.authProperties = authProperties; String oauthProvider = Iterables.getOnlyElement(auth2ClientProperties.getRegistration().keySet()); diff --git a/backend/auth/src/main/java/de/eshg/security/auth/RolesResolver.java b/backend/auth/src/main/java/de/eshg/security/auth/RolesResolver.java new file mode 100644 index 0000000000000000000000000000000000000000..8fd6f0956e1ce7ed9151645d914de0fd137af895 --- /dev/null +++ b/backend/auth/src/main/java/de/eshg/security/auth/RolesResolver.java @@ -0,0 +1,36 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.security.auth; + +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTParser; +import de.eshg.lib.keycloak.KeycloakRole; +import java.text.ParseException; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.core.OAuth2AccessToken; + +public class RolesResolver { + + private RolesResolver() {} + + private static final Logger log = LoggerFactory.getLogger(RolesResolver.class); + + public static List<String> getRoles(OAuth2AccessToken accessToken) { + try { + JWT jwt = JWTParser.parse(accessToken.getTokenValue()); + List<String> roles = + jwt.getJWTClaimsSet().getStringListClaim(KeycloakRole.CLAIM_NAME).stream() + .sorted() + .toList(); + log.debug("Roles: {}", roles); + return roles; + } catch (ParseException e) { + throw new UnauthorizedException("Failed to parse the JWT token", e); + } + } +} diff --git a/backend/auth/src/main/java/de/eshg/security/auth/synapse/ConditionalOnSynapseUrl.java b/backend/auth/src/main/java/de/eshg/security/auth/synapse/ConditionalOnSynapseUrl.java new file mode 100644 index 0000000000000000000000000000000000000000..016ef54b2121b2855fa8208b6a097d6efac019ee --- /dev/null +++ b/backend/auth/src/main/java/de/eshg/security/auth/synapse/ConditionalOnSynapseUrl.java @@ -0,0 +1,17 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.security.auth.synapse; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@ConditionalOnProperty(name = "eshg.synapse.internal.url", matchIfMissing = false) +public @interface ConditionalOnSynapseUrl {} diff --git a/backend/auth/src/main/java/de/eshg/security/auth/synapse/MatrixClientUtils.java b/backend/auth/src/main/java/de/eshg/security/auth/synapse/MatrixClientUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..06da096646a56daeeb314a1658a566532b082464 --- /dev/null +++ b/backend/auth/src/main/java/de/eshg/security/auth/synapse/MatrixClientUtils.java @@ -0,0 +1,27 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.security.auth.synapse; + +import de.eshg.security.auth.AuthProperties; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +final class MatrixClientUtils { + + private MatrixClientUtils() {} + + static String replaceSchemeHostAndPort(String basePath, AuthProperties authProperties) { + UriComponents configuredBaseUri = + UriComponentsBuilder.fromUri(authProperties.synapse().internal().url()).build(); + + return UriComponentsBuilder.fromUriString(basePath) + .scheme(configuredBaseUri.getScheme()) + .host(configuredBaseUri.getHost()) + .port(configuredBaseUri.getPort()) + .build() + .toString(); + } +} diff --git a/backend/auth/src/main/java/de/eshg/security/auth/synapse/MatrixLoginClient.java b/backend/auth/src/main/java/de/eshg/security/auth/synapse/MatrixLoginClient.java new file mode 100644 index 0000000000000000000000000000000000000000..dcf6fb2460dddb78143aa142b0937e250b3f0670 --- /dev/null +++ b/backend/auth/src/main/java/de/eshg/security/auth/synapse/MatrixLoginClient.java @@ -0,0 +1,71 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.security.auth.synapse; + +import de.eshg.security.auth.AuthProperties; +import java.time.Clock; +import java.time.Instant; +import java.util.Objects; +import org.matrix.login.ApiClient; +import org.matrix.login.api.SessionManagementApi; +import org.matrix.login.model.Login200Response; +import org.matrix.login.model.LoginRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Component +@ConditionalOnSynapseUrl +public class MatrixLoginClient { + + private static final Logger log = LoggerFactory.getLogger(MatrixLoginClient.class); + + private final SessionManagementApi sessionManagementApi; + private final Clock clock; + + public MatrixLoginClient( + AuthProperties authProperties, RestClient.Builder restClientBuilder, Clock clock) { + ApiClient apiClient = new ApiClient(restClientBuilder.build()); + apiClient.setBasePath( + MatrixClientUtils.replaceSchemeHostAndPort(apiClient.getBasePath(), authProperties)); + this.sessionManagementApi = new SessionManagementApi(apiClient); + this.clock = clock; + } + + SynapseTokenData login(OAuth2AccessToken accessToken, String requestedDeviceId) { + if (requestedDeviceId != null) { + log.debug("Requested login to get new AccessToken for deviceId={}", requestedDeviceId); + } else { + log.debug( + "Requested login for new deviceId: Synapse will generate new deviceId. " + + "Matrix Client must now setup cross signing from 4S backup with this new deviceId " + + "to be able to access encrypted messages history on that device."); + } + + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setType("org.matrix.login.jwt"); + loginRequest.setRefreshToken(true); + loginRequest.setDeviceId(requestedDeviceId); + loginRequest.setToken(accessToken.getTokenValue()); + + Login200Response response = sessionManagementApi.login(loginRequest); + + String synapseAccessToken = response.getAccessToken(); + Instant expiresAt = + Instant.now(clock) + .plusMillis( + Objects.requireNonNull( + response.getExpiresInMs(), "Access token is expected to expire")); + String synapseRefreshToken = + Objects.requireNonNull(response.getRefreshToken(), "Refresh token expected"); + String deviceId = Objects.requireNonNull(response.getDeviceId(), "DeviceId expected"); + + log.debug("Login successful for deviceId={}", deviceId); + return new SynapseTokenData(synapseAccessToken, expiresAt, synapseRefreshToken, deviceId); + } +} diff --git a/backend/auth/src/main/java/de/eshg/security/auth/synapse/MatrixLogoutClient.java b/backend/auth/src/main/java/de/eshg/security/auth/synapse/MatrixLogoutClient.java new file mode 100644 index 0000000000000000000000000000000000000000..80f9390b4d6dbe99b97109c96fbca5efbb0f393e --- /dev/null +++ b/backend/auth/src/main/java/de/eshg/security/auth/synapse/MatrixLogoutClient.java @@ -0,0 +1,72 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.security.auth.synapse; + +import de.cronn.commons.lang.StreamUtil; +import de.eshg.security.auth.AuthProperties; +import org.matrix.logout.ApiClient; +import org.matrix.logout.api.SessionManagementApi; +import org.matrix.logout.auth.HttpBearerAuth; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Component +@ConditionalOnSynapseUrl +public class MatrixLogoutClient { + + private static final Logger log = LoggerFactory.getLogger(MatrixLogoutClient.class); + + private final SessionManagementApi sessionManagementApi; + private final SynapseTokenDataHolder synapseTokenDataHolder; + private final AuthProperties authProperties; + + public MatrixLogoutClient( + AuthProperties authProperties, + RestClient.Builder restClientBuilder, + SynapseTokenDataHolder synapseTokenDataHolder) { + this.synapseTokenDataHolder = synapseTokenDataHolder; + this.authProperties = authProperties; + ApiClient apiClient = new ApiClient(restClientBuilder.build()); + apiClient.setBasePath( + MatrixClientUtils.replaceSchemeHostAndPort(apiClient.getBasePath(), authProperties)); + configureBearerAuth(apiClient, synapseTokenDataHolder); + this.sessionManagementApi = new SessionManagementApi(apiClient); + } + + private void configureBearerAuth( + ApiClient apiClient, SynapseTokenDataHolder synapseTokenDataHolder) { + HttpBearerAuth httpBearerAuth = + apiClient.getAuthentications().values().stream() + .filter(HttpBearerAuth.class::isInstance) + .map(HttpBearerAuth.class::cast) + .collect(StreamUtil.toSingleElement()); + httpBearerAuth.setBearerToken(() -> synapseTokenDataHolder.getSynapseTokenData().accessToken()); + } + + private boolean isLoggedIn() { + return synapseTokenDataHolder.getSynapseTokenData() != null; + } + + public void logout() { + if (isLoggedIn()) { + if (authProperties.synapse().activeLogoutEnabled()) { + log.debug( + "Calling Synapse logout for deviceId={}.", + synapseTokenDataHolder.getSynapseTokenData().deviceId()); + sessionManagementApi.logout(); + } else { + log.warn( + "Active Logout is disabled until proper SSSS backup handling is implemented in the frontend. " + + "Reason: Calling synapse/logout endpoint destroys deviceId and Olm session on the server. " + + "Frontend using this deviceId would no longer be able to decrypt incoming messages."); + } + } else { + log.trace("Skipping logout call - No active Synapse session."); + } + } +} diff --git a/backend/auth/src/main/java/de/eshg/security/auth/synapse/MatrixRefreshClient.java b/backend/auth/src/main/java/de/eshg/security/auth/synapse/MatrixRefreshClient.java new file mode 100644 index 0000000000000000000000000000000000000000..700efd9b9fb16d961f735ea068ef2f143f73a605 --- /dev/null +++ b/backend/auth/src/main/java/de/eshg/security/auth/synapse/MatrixRefreshClient.java @@ -0,0 +1,59 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.security.auth.synapse; + +import de.eshg.security.auth.AuthProperties; +import java.time.Clock; +import java.time.Instant; +import java.util.Objects; +import org.matrix.refresh.ApiClient; +import org.matrix.refresh.api.DefaultApi; +import org.matrix.refresh.model.Refresh200Response; +import org.matrix.refresh.model.RefreshRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Component +@ConditionalOnSynapseUrl +public class MatrixRefreshClient { + + private static final Logger log = LoggerFactory.getLogger(MatrixRefreshClient.class); + + private final DefaultApi refreshApi; + private final Clock clock; + + public MatrixRefreshClient( + AuthProperties authProperties, RestClient.Builder restClientBuilder, Clock clock) { + ApiClient apiClient = new ApiClient(restClientBuilder.build()); + apiClient.setBasePath( + MatrixClientUtils.replaceSchemeHostAndPort(apiClient.getBasePath(), authProperties)); + this.refreshApi = new DefaultApi(apiClient); + this.clock = clock; + } + + SynapseTokenData refresh(SynapseTokenData synapseTokenData) { + log.debug("Refreshing Synapse AccessToken for deviceId={}", synapseTokenData.deviceId()); + + RefreshRequest refreshRequest = new RefreshRequest(); + refreshRequest.setRefreshToken(synapseTokenData.refreshToken()); + + Refresh200Response response = refreshApi.refresh(refreshRequest); + + String synapseAccessToken = response.getAccessToken(); + Instant expiresAt = + Instant.now(clock) + .plusMillis( + Objects.requireNonNull( + response.getExpiresInMs(), "Access token is expected to expire")); + String synapseRefreshToken = + Objects.requireNonNull(response.getRefreshToken(), "Refresh token expected"); + String deviceId = Objects.requireNonNull(synapseTokenData.deviceId(), "DeviceId expected"); + + return new SynapseTokenData(synapseAccessToken, expiresAt, synapseRefreshToken, deviceId); + } +} diff --git a/backend/auth/src/main/java/de/eshg/security/auth/synapse/SynapseAuthController.java b/backend/auth/src/main/java/de/eshg/security/auth/synapse/SynapseAuthController.java new file mode 100644 index 0000000000000000000000000000000000000000..27d732a10478e593e95bc7f1dccaf2d251424eb8 --- /dev/null +++ b/backend/auth/src/main/java/de/eshg/security/auth/synapse/SynapseAuthController.java @@ -0,0 +1,102 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.security.auth.synapse; + +import de.eshg.lib.keycloak.EmployeePermissionRole; +import de.eshg.security.auth.AuthProperties; +import de.eshg.security.auth.ForbiddenException; +import de.eshg.security.auth.RolesResolver; +import io.swagger.v3.oas.annotations.Hidden; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(SynapseAuthController.BASE_URL) +@ConditionalOnSynapseUrl +@Hidden +public class SynapseAuthController { + + private static final Logger log = LoggerFactory.getLogger(SynapseAuthController.class); + + public static final String X_FORWARDED_MATRIX_DEVICE_ID = "X-Forwarded-Matrix-Device-Id"; + + public static final String BASE_URL = "/synapse"; + + private final AuthProperties authProperties; + private final MatrixLoginClient matrixLoginClient; + private final MatrixRefreshClient matrixRefreshClient; + private final Clock clock; + private final SynapseTokenDataHolder synapseTokenDataHolder; + + public SynapseAuthController( + AuthProperties authProperties, + MatrixLoginClient matrixLoginClient, + MatrixRefreshClient matrixRefreshClient, + Clock clock, + SynapseTokenDataHolder synapseTokenDataHolder) { + this.authProperties = authProperties; + this.matrixLoginClient = matrixLoginClient; + this.matrixRefreshClient = matrixRefreshClient; + this.clock = clock; + this.synapseTokenDataHolder = synapseTokenDataHolder; + } + + @GetMapping + ResponseEntity<Void> resolveSynapseAccessToken( + @RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient client, + @RequestHeader(value = X_FORWARDED_MATRIX_DEVICE_ID, required = false) String deviceId) { + OAuth2AccessToken accessToken = client.getAccessToken(); + validateRole(accessToken); + + SynapseTokenData synapseTokenData = getSynapseTokenData(); + + if (synapseTokenData == null || deviceId == null) { + synapseTokenData = matrixLoginClient.login(accessToken, deviceId); + storeSynapseTokenData(synapseTokenData); + } else { + if (tokenRefreshRequired(synapseTokenData)) { + synapseTokenData = matrixRefreshClient.refresh(synapseTokenData); + storeSynapseTokenData(synapseTokenData); + } + } + return ResponseEntity.ok() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + synapseTokenData.accessToken()) + .build(); + } + + private static void validateRole(OAuth2AccessToken accessToken) { + List<String> roles = RolesResolver.getRoles(accessToken); + if (!roles.contains(EmployeePermissionRole.CHAT_MANAGEMENT_WRITE.name())) { + throw new ForbiddenException("Required role is missing"); + } + } + + private SynapseTokenData getSynapseTokenData() { + return synapseTokenDataHolder.getSynapseTokenData(); + } + + private void storeSynapseTokenData(SynapseTokenData synapseTokenData) { + synapseTokenDataHolder.setSynapseTokenData(synapseTokenData); + } + + private boolean tokenRefreshRequired(SynapseTokenData synapseTokenData) { + Instant instantOfRequiredRefresh = + synapseTokenData.expiresAt().minus(authProperties.synapse().refreshClockSkew()); + return Instant.now(clock).isAfter(instantOfRequiredRefresh); + } +} diff --git a/backend/auth/src/main/java/de/eshg/security/auth/synapse/SynapseLogoutHandler.java b/backend/auth/src/main/java/de/eshg/security/auth/synapse/SynapseLogoutHandler.java new file mode 100644 index 0000000000000000000000000000000000000000..304f41d88229a5f1c3d2f4c8b5126fd26f98d9eb --- /dev/null +++ b/backend/auth/src/main/java/de/eshg/security/auth/synapse/SynapseLogoutHandler.java @@ -0,0 +1,29 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.security.auth.synapse; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnSynapseUrl +public class SynapseLogoutHandler implements LogoutHandler { + + private final MatrixLogoutClient matrixLogoutClient; + + public SynapseLogoutHandler(MatrixLogoutClient matrixLogoutClient) { + this.matrixLogoutClient = matrixLogoutClient; + } + + @Override + public void logout( + HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + matrixLogoutClient.logout(); + } +} diff --git a/backend/auth/src/main/java/de/eshg/security/auth/synapse/SynapseTokenData.java b/backend/auth/src/main/java/de/eshg/security/auth/synapse/SynapseTokenData.java new file mode 100644 index 0000000000000000000000000000000000000000..4ce69d52c79d48828d1a2c7945cbdaaac5b8c36a --- /dev/null +++ b/backend/auth/src/main/java/de/eshg/security/auth/synapse/SynapseTokenData.java @@ -0,0 +1,13 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.security.auth.synapse; + +import java.io.Serializable; +import java.time.Instant; + +public record SynapseTokenData( + String accessToken, Instant expiresAt, String refreshToken, String deviceId) + implements Serializable {} diff --git a/backend/auth/src/main/java/de/eshg/security/auth/synapse/SynapseTokenDataHolder.java b/backend/auth/src/main/java/de/eshg/security/auth/synapse/SynapseTokenDataHolder.java new file mode 100644 index 0000000000000000000000000000000000000000..ea1568b6337ff70f65fd3ed1c1ee2d7100d5dd07 --- /dev/null +++ b/backend/auth/src/main/java/de/eshg/security/auth/synapse/SynapseTokenDataHolder.java @@ -0,0 +1,28 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.security.auth.synapse; + +import java.io.Serial; +import java.io.Serializable; +import org.springframework.stereotype.Component; +import org.springframework.web.context.annotation.SessionScope; + +@Component +@SessionScope +public class SynapseTokenDataHolder implements Serializable { + + @Serial private static final long serialVersionUID = 1L; + + private SynapseTokenData synapseTokenData; + + public SynapseTokenData getSynapseTokenData() { + return synapseTokenData; + } + + public void setSynapseTokenData(SynapseTokenData synapseTokenData) { + this.synapseTokenData = synapseTokenData; + } +} diff --git a/backend/auth/src/main/resources/application-citizen-portal.properties b/backend/auth/src/main/resources/application-citizen-portal.properties index 5616d82c7655e25e687f3eb14c4231da06678cc6..9535579d6319b54b7bb5e2d39eb201bdabb5fb2b 100644 --- a/backend/auth/src/main/resources/application-citizen-portal.properties +++ b/backend/auth/src/main/resources/application-citizen-portal.properties @@ -8,7 +8,7 @@ eshg.auth.language-path-prefixes=/de, /en eshg.auth.access-code-url-patterns[SCHOOL_ENTRY]=/einschulungsuntersuchung/termin eshg.auth.access-code-url-patterns[TRAVEL_MEDICINE]=/impfberatung/meine-termine -eshg.auth.access-code-url-patterns[STI_PROTECTION]=/sexuellegesundheit/hiv-sti-beratung/termin +eshg.auth.access-code-url-patterns[STI_PROTECTION]=/sexuelle-gesundheit/meine-termine eshg.auth.muk-url-patterns=/unternehmen/** eshg.auth.bund-id-url-patterns=/mein-bereich/** diff --git a/backend/auth/src/main/resources/application-employee-portal.properties b/backend/auth/src/main/resources/application-employee-portal.properties index 511337ead490d841d36bbb236de2ffaf9ab6edc8..8769fefe01a8c4b77df8348013b61e538e5958ff 100644 --- a/backend/auth/src/main/resources/application-employee-portal.properties +++ b/backend/auth/src/main/resources/application-employee-portal.properties @@ -3,3 +3,7 @@ eshg.realm=eshg eshg.reverse-proxy.url=http://localhost:4000 spring.security.oauth2.client.registration.keycloak.client-secret=jPKtsvmKqRqsscNnN7NMVFhmf3b9NH + +eshg.synapse.internal.url=http://${DOCKER_HOSTNAME:localhost}:8008 +eshg.synapse.refresh-clock-skew=PT1M +eshg.synapse.active-logout-enabled=false diff --git a/backend/auth/src/main/resources/application.properties b/backend/auth/src/main/resources/application.properties index e9fdaf577081a00c8ce1b206e19361e90369ac9e..639fc39e57e960f93c600e9347e2e79e848c3d13 100644 --- a/backend/auth/src/main/resources/application.properties +++ b/backend/auth/src/main/resources/application.properties @@ -18,6 +18,12 @@ logging.level.de.eshg.security.auth=DEBUG logging.level.org.springframework.security=DEBUG logging.level.org.zalando.logbook=TRACE +logbook.obfuscate.json-body-fields[0]=password +logbook.obfuscate.json-body-fields[1]=access_token +logbook.obfuscate.json-body-fields[2]=refresh_token +logbook.obfuscate.json-body-fields[3]=token +logbook.obfuscate.json-body-fields[4]=refreshToken +logbook.obfuscate.json-body-fields[5]=accessToken # Keep this setting in sync with "eshg.keycloak.session-timeout" of the "base" module spring.session.timeout=30m 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 b9cac5f655ad05f03006d99ccfc362a5e999fcc9..349bfde5abacd6644a08490f1f03a331c213bd22 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 @@ -90,6 +90,35 @@ public interface PersonApi { @RequestParam(name = "dateOfBirth") LocalDate dateOfBirth); + @GetExchange("/partial") + @ApiResponse(responseCode = "200") + @Operation( + summary = + """ +Search reference persons for the given knowledge factors 'firstName', 'lastName' and 'dateOfBirth', +without the need for specifying all three. However, searching for only a first name or only a last +name is prohibited. +Excludes persons created from external sources. +Caution: The returned ids of the reference persons must not be stored. +""") + SearchReferencePersonsWithPartialKnowledgeFactorsResponse + searchReferencePersonsWithPartialKnowledgeFactors( + @Parameter( + description = + "The first name of the Person (1 of 3 knowledge factors) which shall be searched for.") + @RequestParam(name = "firstName") + String firstName, + @Parameter( + description = + "The last name of the Person (1 of 3 knowledge factors) which shall be searched for.") + @RequestParam(name = "lastName") + String lastName, + @Parameter( + description = + "The date of birth of the Person (1 of 3 knowledge factors) which shall be searched for.") + @RequestParam(name = "dateOfBirth") + LocalDate dateOfBirth); + @GetExchange(FILE_STATES_URL + "/{id}/linked-ids") @ApiResponse(responseCode = "200") @Operation( diff --git a/backend/base-api/src/main/java/de/eshg/base/centralfile/api/person/SearchReferencePersonsWithPartialKnowledgeFactorsResponse.java b/backend/base-api/src/main/java/de/eshg/base/centralfile/api/person/SearchReferencePersonsWithPartialKnowledgeFactorsResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..e806645ca61d19fc04536588e8f0653c858fb343 --- /dev/null +++ b/backend/base-api/src/main/java/de/eshg/base/centralfile/api/person/SearchReferencePersonsWithPartialKnowledgeFactorsResponse.java @@ -0,0 +1,13 @@ +/* + * Copyright 2025 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; + +public record SearchReferencePersonsWithPartialKnowledgeFactorsResponse( + @Valid @NotNull List<GetReferencePersonResponse> persons, @NotNull boolean overflow) {} diff --git a/backend/base-api/src/main/java/de/eshg/base/mail/MailType.java b/backend/base-api/src/main/java/de/eshg/base/mail/MailType.java new file mode 100644 index 0000000000000000000000000000000000000000..b7c5e90111ddb7e5975e586abd199a284e2b7295 --- /dev/null +++ b/backend/base-api/src/main/java/de/eshg/base/mail/MailType.java @@ -0,0 +1,11 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.base.mail; + +public enum MailType { + PLAIN_TEXT, + HTML, +} diff --git a/backend/base-api/src/main/java/de/eshg/base/mail/SendEmailRequest.java b/backend/base-api/src/main/java/de/eshg/base/mail/SendEmailRequest.java index 2c04451bfb2dea304659fb769fa83a54ce4d145d..601e2fede525b6906f4a11fe161029f31a6bca92 100644 --- a/backend/base-api/src/main/java/de/eshg/base/mail/SendEmailRequest.java +++ b/backend/base-api/src/main/java/de/eshg/base/mail/SendEmailRequest.java @@ -25,7 +25,15 @@ public record SendEmailRequest( @Schema(description = "The subject of the email", example = "Important test email") @NotBlank String subject, @Schema( - description = "The content of the email. Currently only plain text is possible", - example = "Dear John Doe, this a test. Best regards, Jane Doe") + description = + "The content of the email. If the type is HTML, this should be an HTML fragment; otherwise, it should be plain text.", + example = + "PLAIN_TEXT: 'Dear John Doe,\nthis a test.\nBest regards,\nJane Doe' HTML: 'Dear John Doe,<br>this a test.<br>Best regards,<br>Jane Doe'") @NotBlank - String text) {} + String text, + @Schema( + description = + "The content type of the email. PLAIN_TEXT mails will be sent verbatim. for HTML mails the text will be embedded in a template with a GA specific header and footer.", + example = "PLAIN_TEXT") + @NotNull + MailType type) {} diff --git a/backend/base-api/src/main/java/de/eshg/base/statistics/BaseStatisticsApi.java b/backend/base-api/src/main/java/de/eshg/base/statistics/BaseStatisticsApi.java index ce868811846fdb3e74a5baf286a250136a734117..2bdb3f8badf0cf1fd94355cf441ea1a86da992cc 100644 --- a/backend/base-api/src/main/java/de/eshg/base/statistics/BaseStatisticsApi.java +++ b/backend/base-api/src/main/java/de/eshg/base/statistics/BaseStatisticsApi.java @@ -8,6 +8,8 @@ package de.eshg.base.statistics; import de.eshg.base.statistics.api.GetBaseDataSourcesResponse; import de.eshg.base.statistics.api.GetBaseStatisticsDataRequest; import de.eshg.base.statistics.api.GetBaseStatisticsDataResponse; +import de.eshg.base.statistics.api.GetBaseStatisticsDataTableHeaderRequest; +import de.eshg.base.statistics.api.GetBaseStatisticsDataTableHeaderResponse; import de.eshg.lib.statistics.StatisticsApi; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -27,6 +29,12 @@ public interface BaseStatisticsApi { @Operation(summary = "Get available data sources") GetBaseDataSourcesResponse getAvailableDataSources(); + @PostExchange("/data-table-header") + @Operation(summary = "Get the data table header for the requested attributes") + GetBaseStatisticsDataTableHeaderResponse getDataTableHeader( + @Valid @RequestBody + GetBaseStatisticsDataTableHeaderRequest getBaseStatisticsDataTableHeaderRequest); + @PostExchange("/specific-data") @Operation(summary = "Get specific data for the requested attributes") GetBaseStatisticsDataResponse getSpecificData( diff --git a/backend/base-api/src/main/java/de/eshg/base/statistics/api/GetBaseStatisticsDataRequest.java b/backend/base-api/src/main/java/de/eshg/base/statistics/api/GetBaseStatisticsDataRequest.java index 61ee3fa225c2e9066aa525649922e5ac22d4a4d2..797a6514bdd0a84844d71a375e384903ac699a3d 100644 --- a/backend/base-api/src/main/java/de/eshg/base/statistics/api/GetBaseStatisticsDataRequest.java +++ b/backend/base-api/src/main/java/de/eshg/base/statistics/api/GetBaseStatisticsDataRequest.java @@ -13,4 +13,4 @@ import java.util.UUID; public record GetBaseStatisticsDataRequest( @NotBlank String dataSourceName, @NotNull List<String> attributeCodes, - @NotNull List<UUID> centralFileIds) {} + @NotNull List<UUID> baseIds) {} diff --git a/backend/base-api/src/main/java/de/eshg/base/statistics/api/GetBaseStatisticsDataTableHeaderRequest.java b/backend/base-api/src/main/java/de/eshg/base/statistics/api/GetBaseStatisticsDataTableHeaderRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..4fe5d441afc390e78973b13ef3ddd8484876f179 --- /dev/null +++ b/backend/base-api/src/main/java/de/eshg/base/statistics/api/GetBaseStatisticsDataTableHeaderRequest.java @@ -0,0 +1,13 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.base.statistics.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record GetBaseStatisticsDataTableHeaderRequest( + @NotBlank String dataSourceName, @NotNull List<String> attributeCodes) {} diff --git a/backend/base-api/src/main/java/de/eshg/base/statistics/api/GetBaseStatisticsDataTableHeaderResponse.java b/backend/base-api/src/main/java/de/eshg/base/statistics/api/GetBaseStatisticsDataTableHeaderResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..0fb4c7336a36addec9de6894e810805a2e35e6c0 --- /dev/null +++ b/backend/base-api/src/main/java/de/eshg/base/statistics/api/GetBaseStatisticsDataTableHeaderResponse.java @@ -0,0 +1,12 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.base.statistics.api; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +public record GetBaseStatisticsDataTableHeaderResponse( + @NotNull @Valid BaseDataTableHeader dataTableHeader) {} diff --git a/backend/base-api/src/main/java/de/eshg/base/user/api/UserRoleDto.java b/backend/base-api/src/main/java/de/eshg/base/user/api/UserRoleDto.java index e35a57235bb9e7dc8fb71d17700d7ebf28a7e8b9..23af5108b5d4106b3d06e319f47fb9d8a5f480c3 100644 --- a/backend/base-api/src/main/java/de/eshg/base/user/api/UserRoleDto.java +++ b/backend/base-api/src/main/java/de/eshg/base/user/api/UserRoleDto.java @@ -64,6 +64,7 @@ public enum UserRoleDto { STATISTICS_STATISTICS_READ, STATISTICS_STATISTICS_WRITE, STATISTICS_STATISTICS_ADMIN, + STATISTICS_STATISTICS_TECHNICAL_USER, BASE_MAIL_SEND, INBOX_PROCEDURE_WRITE, PROCEDURE_ARCHIVE, diff --git a/backend/base/build.gradle b/backend/base/build.gradle index 96fb03446cdee2e3c28cccb45620d7df1ba8f950..c0a5f3ff9f2ba06ba52faee82d5e56e4098f6f67 100644 --- a/backend/base/build.gradle +++ b/backend/base/build.gradle @@ -34,6 +34,7 @@ dependencies { exclude group: "org.codehaus.groovy", module: "*" exclude group: "javax.cache", module: "cache-api" } + implementation 'org.apache.xmlgraphics:batik-transcoder:latest.release' implementation 'com.googlecode.ez-vcard:ez-vcard:latest.release' diff --git a/backend/base/gradle.lockfile b/backend/base/gradle.lockfile index c6b27207d069781b65dcb633557e212e563e2734..fef7a70fe113499710d79e4b5bed2563457649a4 100644 --- a/backend/base/gradle.lockfile +++ b/backend/base/gradle.lockfile @@ -149,25 +149,25 @@ org.apache.tomcat.embed:tomcat-embed-el:10.1.34=compileClasspath,productionRunti org.apache.tomcat.embed:tomcat-embed-websocket:10.1.34=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat:tomcat-annotations-api:10.1.34=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.apache.ws.xmlschema:xmlschema-core:2.3.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-anim:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-awt-util:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-bridge:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-anim:1.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-awt-util:1.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-bridge:1.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.xmlgraphics:batik-codec:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-constants:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-css:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-dom:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-ext:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-gvt:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-i18n:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-parser:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-script:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-shared-resources:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-svg-dom:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-svggen:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-transcoder:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-util:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:batik-xml:1.17=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.xmlgraphics:xmlgraphics-commons:2.9=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-constants:1.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-css:1.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-dom:1.18=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-ext:1.18=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-gvt:1.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-i18n:1.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-parser:1.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-script:1.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-shared-resources:1.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-svg-dom:1.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-svggen:1.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-transcoder:1.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-util:1.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xmlgraphics:batik-xml:1.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.xmlgraphics:xmlgraphics-commons:2.9=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apiguardian:apiguardian-api:1.1.2=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.aspectj:aspectjweaver:1.9.22.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.assertj:assertj-core:3.26.3=testCompileClasspath,testRuntimeClasspath @@ -324,6 +324,6 @@ org.zalando:logbook-servlet:3.10.0=productionRuntimeClasspath,runtimeClasspath,t org.zalando:logbook-spring-boot-autoconfigure:3.10.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.zalando:logbook-spring-boot-starter:3.10.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.zalando:logbook-spring:3.10.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -xml-apis:xml-apis-ext:1.3.04=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -xml-apis:xml-apis:1.4.01=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +xml-apis:xml-apis-ext:1.3.04=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +xml-apis:xml-apis:1.4.01=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath empty=developmentOnly,testAndDevelopmentOnly,testAnnotationProcessor,testFixturesCompileClasspath,testFixturesRuntimeClasspath diff --git a/backend/base/openApi.json b/backend/base/openApi.json index af097e1032c90b4e9f0945e55be943655ceb7e29..3e3626b3e2eb4be1154a7d5ec2b5b7973bfdb60f 100644 --- a/backend/base/openApi.json +++ b/backend/base/openApi.json @@ -3550,6 +3550,51 @@ "tags" : [ "Person" ] } }, + "/persons/partial" : { + "get" : { + "operationId" : "searchReferencePersonsWithPartialKnowledgeFactors", + "parameters" : [ { + "description" : "The first name of the Person (1 of 3 knowledge factors) which shall be searched for.", + "in" : "query", + "name" : "firstName", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "description" : "The last name of the Person (1 of 3 knowledge factors) which shall be searched for.", + "in" : "query", + "name" : "lastName", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "description" : "The date of birth of the Person (1 of 3 knowledge factors) which shall be searched for.", + "in" : "query", + "name" : "dateOfBirth", + "required" : true, + "schema" : { + "type" : "string", + "format" : "date" + } + } ], + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SearchReferencePersonsWithPartialKnowledgeFactorsResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Search reference persons for the given knowledge factors 'firstName', 'lastName' and 'dateOfBirth',\nwithout the need for specifying all three. However, searching for only a first name or only a last\nname is prohibited.\nExcludes persons created from external sources.\nCaution: The returned ids of the reference persons must not be stored.\n", + "tags" : [ "Person" ] + } + }, "/persons/reference/{id}/linked-ids" : { "get" : { "operationId" : "getPersonFileStateIdsAssociatedWithReferencePerson", @@ -9659,6 +9704,12 @@ }, "description" : "Location defined by latitude and longitude." }, + "MailType" : { + "type" : "string", + "description" : "The content type of the email. PLAIN_TEXT mails will be sent verbatim. for HTML mails the text will be embedded in a template with a GA specific header and footer.", + "example" : "PLAIN_TEXT", + "enum" : [ "PLAIN_TEXT", "HTML" ] + }, "ManualProgressEntryType" : { "type" : "string", "enum" : [ "LETTER", "PHONE_CALL", "NOTE", "EMAIL", "IMAGE", "DOCUMENT" ] @@ -10466,6 +10517,21 @@ } } }, + "SearchReferencePersonsWithPartialKnowledgeFactorsResponse" : { + "required" : [ "overflow", "persons" ], + "type" : "object", + "properties" : { + "overflow" : { + "type" : "boolean" + }, + "persons" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/GetReferencePersonResponse" + } + } + } + }, "SearchStreetResponse" : { "required" : [ "cityDistricts" ], "type" : "object", @@ -10513,7 +10579,7 @@ } }, "SendEmailRequest" : { - "required" : [ "subject", "text", "to" ], + "required" : [ "subject", "text", "to", "type" ], "type" : "object", "properties" : { "from" : { @@ -10530,8 +10596,8 @@ }, "text" : { "type" : "string", - "description" : "The content of the email. Currently only plain text is possible", - "example" : "Dear John Doe, this a test. Best regards, Jane Doe" + "description" : "The content of the email. If the type is HTML, this should be an HTML fragment; otherwise, it should be plain text.", + "example" : "PLAIN_TEXT: 'Dear John Doe,\nthis a test.\nBest regards,\nJane Doe' HTML: 'Dear John Doe,<br>this a test.<br>Best regards,<br>Jane Doe'" }, "to" : { "maxLength" : 254, @@ -10539,6 +10605,9 @@ "type" : "string", "description" : "The email address of the recipient of the email", "example" : "recipient@example.com" + }, + "type" : { + "$ref" : "#/components/schemas/MailType" } } }, @@ -11411,7 +11480,7 @@ "UserRole" : { "type" : "string", "description" : "A filter for a role users can have", - "enum" : [ "INSPECTION_LEADER", "INSPECTION_LANDESAMT_LEADER", "SCHOOL_ENTRY_LEADER", "TRAVEL_MEDICINE_LEADER", "MEASLES_PROTECTION_LEADER", "STATISTICS_LEADER", "BASE_PERSONS_READ", "BASE_PERSONS_WRITE", "BASE_PERSONS_DELETE", "BASE_FACILITIES_READ", "BASE_FACILITIES_WRITE", "BASE_FACILITIES_DELETE", "BASE_RESOURCES_READ", "BASE_RESOURCES_WRITE", "BASE_INVENTORY_READ", "BASE_INVENTORY_USE", "BASE_INVENTORY_ADMINISTRATE", "BASE_LABELS_READ", "BASE_LABELS_WRITE", "BASE_CONTACTS_READ", "BASE_CONTACTS_WRITE", "BASE_GDPR_PROCEDURE_REVIEW", "BASE_GDPR_PROCEDURE_READ", "BASE_GDPR_PROCEDURE_WRITE", "BASE_MUK_FACILITY_LINK_WRITE", "BASE_BUNDID_PERSON_LINK_WRITE", "BASE_GLOBAL_CALENDARS_WRITE", "BASE_CALENDAR_BUSINESS_EVENTS_WRITE", "BASE_PROCEDURES_READ", "BASE_PROCEDURE_METRICS_READ", "BASE_TASKS_READ", "BASE_ACCESS_CODE_USER_ADMIN", "BASE_ACCESS_CODE_USER_VERIFY", "SCHOOL_ENTRY_ADMIN", "INSPECTION_NOTIFICATIONS_READ", "INSPECTION_PROCEDURE_EDIT", "INSPECTION_PROCEDURE_ASSIGN", "INSPECTION_OBJECTTYPES_READ", "INSPECTION_OBJECTTYPES_WRITE", "INSPECTION_CHECKLISTDEFINITIONS_READ", "INSPECTION_CHECKLISTDEFINITIONS_WRITE", "INSPECTION_CORECHECKLISTDEFINITIONS_EDIT", "INSPECTION_CENTRALREPOSITORY_READ", "INSPECTION_CENTRALREPOSITORY_WRITE", "INSPECTION_CENTRALREPOSITORY_DELETE", "INSPECTION_CENTRALREPOSITORY_WRITE_CORECHECKLISTS", "INSPECTION_IMPORT", "TRAVEL_MEDICINE_ADMIN", "MEASLES_PROTECTION_ADMIN", "CHAT_MANAGEMENT_WRITE", "STATISTICS_STATISTICS_READ", "STATISTICS_STATISTICS_WRITE", "STATISTICS_STATISTICS_ADMIN", "BASE_MAIL_SEND", "INBOX_PROCEDURE_WRITE", "PROCEDURE_ARCHIVE", "PROCEDURE_ARCHIVE_ADMIN", "AUDITLOG_FILE_SEND", "AUDITLOG_DECRYPT_AND_ACCESS", "AUDITLOG_AUTHORIZE_ACCESS", "AUDITLOG_PUBLIC_KEYS_READ", "STANDARD_EMPLOYEE", "STI_PROTECTION_USER", "STI_PROTECTION_MFA", "STI_PROTECTION_CONSULTANT", "STI_PROTECTION_PHYSICIAN", "STI_PROTECTION_ADMIN", "STI_PROTECTION_LEADER", "MEDICAL_REGISTRY_LEADER", "MEDICAL_REGISTRY_ADMIN", "DENTAL_LEADER", "DENTAL_ADMIN", "OPEN_DATA_ADMIN", "OPEN_DATA_LEADER", "MEDICAL_REGISTRY_IMPORT", "OFFICIAL_MEDICAL_SERVICE_LEADER", "OFFICIAL_MEDICAL_SERVICE_ADMIN", "BASE_GDPR_VALIDATION_TASK_CLEANUP" ] + "enum" : [ "INSPECTION_LEADER", "INSPECTION_LANDESAMT_LEADER", "SCHOOL_ENTRY_LEADER", "TRAVEL_MEDICINE_LEADER", "MEASLES_PROTECTION_LEADER", "STATISTICS_LEADER", "BASE_PERSONS_READ", "BASE_PERSONS_WRITE", "BASE_PERSONS_DELETE", "BASE_FACILITIES_READ", "BASE_FACILITIES_WRITE", "BASE_FACILITIES_DELETE", "BASE_RESOURCES_READ", "BASE_RESOURCES_WRITE", "BASE_INVENTORY_READ", "BASE_INVENTORY_USE", "BASE_INVENTORY_ADMINISTRATE", "BASE_LABELS_READ", "BASE_LABELS_WRITE", "BASE_CONTACTS_READ", "BASE_CONTACTS_WRITE", "BASE_GDPR_PROCEDURE_REVIEW", "BASE_GDPR_PROCEDURE_READ", "BASE_GDPR_PROCEDURE_WRITE", "BASE_MUK_FACILITY_LINK_WRITE", "BASE_BUNDID_PERSON_LINK_WRITE", "BASE_GLOBAL_CALENDARS_WRITE", "BASE_CALENDAR_BUSINESS_EVENTS_WRITE", "BASE_PROCEDURES_READ", "BASE_PROCEDURE_METRICS_READ", "BASE_TASKS_READ", "BASE_ACCESS_CODE_USER_ADMIN", "BASE_ACCESS_CODE_USER_VERIFY", "SCHOOL_ENTRY_ADMIN", "INSPECTION_NOTIFICATIONS_READ", "INSPECTION_PROCEDURE_EDIT", "INSPECTION_PROCEDURE_ASSIGN", "INSPECTION_OBJECTTYPES_READ", "INSPECTION_OBJECTTYPES_WRITE", "INSPECTION_CHECKLISTDEFINITIONS_READ", "INSPECTION_CHECKLISTDEFINITIONS_WRITE", "INSPECTION_CORECHECKLISTDEFINITIONS_EDIT", "INSPECTION_CENTRALREPOSITORY_READ", "INSPECTION_CENTRALREPOSITORY_WRITE", "INSPECTION_CENTRALREPOSITORY_DELETE", "INSPECTION_CENTRALREPOSITORY_WRITE_CORECHECKLISTS", "INSPECTION_IMPORT", "TRAVEL_MEDICINE_ADMIN", "MEASLES_PROTECTION_ADMIN", "CHAT_MANAGEMENT_WRITE", "STATISTICS_STATISTICS_READ", "STATISTICS_STATISTICS_WRITE", "STATISTICS_STATISTICS_ADMIN", "STATISTICS_STATISTICS_TECHNICAL_USER", "BASE_MAIL_SEND", "INBOX_PROCEDURE_WRITE", "PROCEDURE_ARCHIVE", "PROCEDURE_ARCHIVE_ADMIN", "AUDITLOG_FILE_SEND", "AUDITLOG_DECRYPT_AND_ACCESS", "AUDITLOG_AUTHORIZE_ACCESS", "AUDITLOG_PUBLIC_KEYS_READ", "STANDARD_EMPLOYEE", "STI_PROTECTION_USER", "STI_PROTECTION_MFA", "STI_PROTECTION_CONSULTANT", "STI_PROTECTION_PHYSICIAN", "STI_PROTECTION_ADMIN", "STI_PROTECTION_LEADER", "MEDICAL_REGISTRY_LEADER", "MEDICAL_REGISTRY_ADMIN", "DENTAL_LEADER", "DENTAL_ADMIN", "OPEN_DATA_ADMIN", "OPEN_DATA_LEADER", "MEDICAL_REGISTRY_IMPORT", "OFFICIAL_MEDICAL_SERVICE_LEADER", "OFFICIAL_MEDICAL_SERVICE_ADMIN", "BASE_GDPR_VALIDATION_TASK_CLEANUP" ] }, "VCardAddress" : { "required" : [ "addressAddition", "city", "country", "houseNumber", "postBox", "postalCode", "street" ], diff --git a/backend/base/src/main/java/de/eshg/base/bundid/BundIdPersonLinkController.java b/backend/base/src/main/java/de/eshg/base/bundid/BundIdPersonLinkController.java index 0fdebc11b803e1d4f42c89a2353737ad07f74996..fc0d0decd1340dc21afe8b8aee87e9d87e1a1cd5 100644 --- a/backend/base/src/main/java/de/eshg/base/bundid/BundIdPersonLinkController.java +++ b/backend/base/src/main/java/de/eshg/base/bundid/BundIdPersonLinkController.java @@ -51,7 +51,7 @@ public class BundIdPersonLinkController implements BundIdPersonLinkApi { } @Override - @Transactional + @Transactional(readOnly = true) public GetReferencePersonResponse getReferencePersonLinkedToBundIdSelfUser() { featureToggle.assertNewFeatureIsEnabled(BaseFeature.BUNDID_PERSON_LINK); diff --git a/backend/base/src/main/java/de/eshg/base/centralfile/CentralFileCleanupService.java b/backend/base/src/main/java/de/eshg/base/centralfile/CentralFileCleanupService.java index f950209e2cbbf27cd0472f415afd142cbdc0a7c8..f32d9699e31979c05f4e4bfe5098578ab2137764 100644 --- a/backend/base/src/main/java/de/eshg/base/centralfile/CentralFileCleanupService.java +++ b/backend/base/src/main/java/de/eshg/base/centralfile/CentralFileCleanupService.java @@ -9,6 +9,8 @@ import de.eshg.base.centralfile.persistence.FacilityService; import de.eshg.base.centralfile.persistence.PersonService; import java.time.Clock; import java.time.Instant; +import net.javacrumbs.shedlock.core.LockAssert; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -31,13 +33,21 @@ public class CentralFileCleanupService { } @Scheduled(cron = "${de.eshg.central-file-deletion-service.schedule:@daily}") + @SchedulerLock( + name = "BaseCentralFileCleanupServiceFacilities", + lockAtMostFor = "${de.eshg.central-file-deletion-service.lock-at-most-for:23h}") void performCleanUpForFacilities() { + LockAssert.assertLocked(); Instant expirationTime = Instant.now(clock); deleteExpiredFacilityFileStates(expirationTime); } @Scheduled(cron = "${de.eshg.central-file-deletion-service.schedule:@daily}") + @SchedulerLock( + name = "BaseCentralFileCleanupServicePersons", + lockAtMostFor = "${de.eshg.central-file-deletion-service.lock-at-most-for:23h}") void performCleanupForPersons() { + LockAssert.assertLocked(); Instant expirationTime = Instant.now(clock); deleteExpiredPersonFileStates(expirationTime); } 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 23bbc1d62cc9f2f75a950e662c1e692da8b6ded1..96e98d66fe171851c47d1883d8edbdba01bf0776 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 @@ -47,6 +47,8 @@ public class PersonController implements PersonApi { private static final String PERSON_FILE_STATE_NOT_FOUND = "PersonFileState not found"; public static final String REFERENCE_PERSON_NOT_FOUND = "ReferencePerson not found"; + private static final int MAX_RESULTS_FOR_PERSON_SEARCH_WITH_PARTIAL_KNOWLEDGE_FACTORS = 100; + private final PersonRepository personRepository; private final PersonService personService; private final Clock clock; @@ -98,11 +100,33 @@ public class PersonController implements PersonApi { public SearchReferencePersonsResponse searchReferencePersons( String firstName, String lastName, LocalDate dateOfBirth) { return new SearchReferencePersonsResponse( - personService.fuzzySearch(firstName, lastName, dateOfBirth).stream() + personService.fuzzySearch(firstName, lastName, dateOfBirth, false, null).stream() .map(PersonMapper::mapReferencePersonToApi) .toList()); } + @Override + @Transactional(readOnly = true) + public SearchReferencePersonsWithPartialKnowledgeFactorsResponse + searchReferencePersonsWithPartialKnowledgeFactors( + String firstName, String lastName, LocalDate dateOfBirth) { + List<Person> fuzzySearchResult = + personService.fuzzySearch( + firstName, + lastName, + dateOfBirth, + true, + MAX_RESULTS_FOR_PERSON_SEARCH_WITH_PARTIAL_KNOWLEDGE_FACTORS + 1); + boolean overflow = + fuzzySearchResult.size() > MAX_RESULTS_FOR_PERSON_SEARCH_WITH_PARTIAL_KNOWLEDGE_FACTORS; + return new SearchReferencePersonsWithPartialKnowledgeFactorsResponse( + fuzzySearchResult.stream() + .limit(MAX_RESULTS_FOR_PERSON_SEARCH_WITH_PARTIAL_KNOWLEDGE_FACTORS) + .map(PersonMapper::mapReferencePersonToApi) + .toList(), + overflow); + } + @Override @Transactional(readOnly = true) public GetReferencePersonResponse getReferencePerson(UUID id) { 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 f54a00fbfea3e34781f78492ce78a99e1ea97beb..bf49f4576866d64acf8559bdea18b5375afa895d 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 @@ -27,6 +27,7 @@ import de.eshg.rest.service.error.BadRequestException; import de.eshg.rest.service.error.ErrorCode; import de.eshg.rest.service.error.NotFoundException; import de.eshg.validation.ValidationUtil; +import io.micrometer.common.util.StringUtils; import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.Predicate; import java.time.Clock; @@ -157,21 +158,37 @@ public class PersonService { .collect(StreamUtil.toLinkedHashSet()); } - public List<Person> fuzzySearch(String firstName, String lastName, LocalDate dateOfBirth) { + public List<Person> fuzzySearch( + String firstName, + String lastName, + LocalDate dateOfBirth, + boolean allowPartialKnowledgeFactors, + Integer limit) { + if (allowPartialKnowledgeFactors) { + if ((StringUtils.isBlank(firstName) && dateOfBirth == null) + || (StringUtils.isBlank(lastName) && dateOfBirth == null)) { + throw new BadRequestException( + ErrorCode.BAD_REQUEST, "Only searching for first name or last name is not allowed."); + } + } else if (StringUtils.isBlank(firstName) + || StringUtils.isBlank(lastName) + || dateOfBirth == null) { + return Collections.emptyList(); + } configureSimilarityThreshold(firstName, lastName); - return fuzzySearch(firstName, lastName, dateOfBirth, false, false); + return fuzzySearch(firstName, lastName, dateOfBirth, false, false, limit); } public List<Person> fuzzySearchIncludingDeletedAndExternal( String firstName, String lastName, LocalDate dateOfBirth) { configureSimilarityThreshold(firstName, lastName); - return fuzzySearch(firstName, lastName, dateOfBirth, true, true); + return fuzzySearch(firstName, lastName, dateOfBirth, true, true, null); } public List<Person> fuzzySearchIncludingDeleted( String firstName, String lastName, LocalDate dateOfBirth) { configureSimilarityThreshold(firstName, lastName); - return fuzzySearch(firstName, lastName, dateOfBirth, true, false); + return fuzzySearch(firstName, lastName, dateOfBirth, true, false, null); } private List<Person> fuzzySearch( @@ -179,7 +196,8 @@ public class PersonService { String lastName, LocalDate dateOfBirth, boolean includeDeleted, - boolean includeExternal) { + boolean includeExternal, + Integer limit) { configureSimilarityThreshold(firstName, lastName); return personRepository.fuzzySearchReferencePersons( firstName, @@ -188,12 +206,15 @@ public class PersonService { getSimilarityThreshold(firstName), getSimilarityThreshold(lastName), includeDeleted, - includeExternal); + includeExternal, + limit); } private void configureSimilarityThreshold(String firstName, String lastName) { - double threshold = - Math.min(getSimilarityThreshold(firstName), getSimilarityThreshold(lastName)); + double similarityThresholdFirstName = + firstName != null ? getSimilarityThreshold(firstName) : 1.0; + double similarityThresholdLastName = lastName != null ? getSimilarityThreshold(lastName) : 1.0; + double threshold = Math.min(similarityThresholdFirstName, similarityThresholdLastName); fuzzySearchHelper.setSimilarityThreshold(threshold); } diff --git a/backend/base/src/main/java/de/eshg/base/centralfile/persistence/repository/PersonRepository.java b/backend/base/src/main/java/de/eshg/base/centralfile/persistence/repository/PersonRepository.java index 9bd4167bf76c6d3c0e041026842cd768ab1297a9..d419ffe9fb04ac5f15cd2ea9ce7be0151a374ddb 100644 --- a/backend/base/src/main/java/de/eshg/base/centralfile/persistence/repository/PersonRepository.java +++ b/backend/base/src/main/java/de/eshg/base/centralfile/persistence/repository/PersonRepository.java @@ -65,18 +65,22 @@ public interface PersonRepository nativeQuery = true, value = """ - select * from person p - where p.reference_person_id is null - and (:includeDeleted = true or p.delete_at is null) - and (:includeExternal = true or p.data_origin <> 'EXTERNAL'::DataOrigin) - and p.date_of_birth = :dateOfBirth - and normalize_text(p.first_name) % normalize_text(:firstName) - and normalize_text(p.last_name) % normalize_text(:lastName) - and similarity(normalize_text(p.first_name), normalize_text(:firstName)) >= :firstNameThreshold - and similarity(normalize_text(p.last_name), normalize_text(:lastName)) >= :lastNameThreshold - order by similarity(normalize_text(p.last_name), normalize_text(:lastName)) - + similarity(normalize_text(p.first_name), normalize_text(:firstName)) desc - """) + select * from person p + where p.reference_person_id is null + and (:includeDeleted = true or p.delete_at is null) + and (:includeExternal = true or p.data_origin <> 'EXTERNAL'::DataOrigin) + and (p.date_of_birth = :dateOfBirth or cast(:dateOfBirth as date) is null) + and (:firstName is null or :firstName = '' or normalize_text(p.first_name) % normalize_text(:firstName)) + and (:lastName is null or :lastName = '' or normalize_text(p.last_name) % normalize_text(:lastName)) + and (:firstName is null or :firstName = '' or similarity(normalize_text(p.first_name), normalize_text(:firstName)) >= :firstNameThreshold) + and (:lastName is null or :lastName = '' or similarity(normalize_text(p.last_name), normalize_text(:lastName)) >= :lastNameThreshold) + order by coalesce(similarity(normalize_text(p.last_name), normalize_text(:lastName)), 0) + + coalesce(similarity(normalize_text(p.first_name), normalize_text(:firstName)), 0) desc, + p.last_name asc, + p.first_name asc, + p.date_of_birth asc + limit :limit + """) List<Person> fuzzySearchReferencePersons( @Param("firstName") String firstName, @Param("lastName") String lastName, @@ -84,7 +88,8 @@ public interface PersonRepository @Param("firstNameThreshold") double firstNameThreshold, @Param("lastNameThreshold") double lastNameThreshold, @Param("includeDeleted") boolean includeDeleted, - @Param("includeExternal") boolean includeExternal); + @Param("includeExternal") boolean includeExternal, + @Param("limit") Integer limit); Optional<Person> findByExternalId(UUID externalId); diff --git a/backend/base/src/main/java/de/eshg/base/gdpr/GdprCleanupJob.java b/backend/base/src/main/java/de/eshg/base/gdpr/GdprCleanupJob.java index 2dc7d021bb0e0aa5fccabf456883e14157c28415..e8fa225fa76b2adf0866f1059addbcfe51f12de8 100644 --- a/backend/base/src/main/java/de/eshg/base/gdpr/GdprCleanupJob.java +++ b/backend/base/src/main/java/de/eshg/base/gdpr/GdprCleanupJob.java @@ -14,6 +14,7 @@ import java.time.Period; import java.time.ZonedDateTime; import java.util.List; import java.util.UUID; +import net.javacrumbs.shedlock.core.LockAssert; import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -51,6 +52,7 @@ public class GdprCleanupJob { @Scheduled(cron = "${eshg.gdpr.cleanup.cron}") @SchedulerLock(name = "GdprCleanupJob", lockAtMostFor = "1h", lockAtLeastFor = "1m") public void executeScheduledCleanup() { + LockAssert.assertLocked(); performGdprCleanup(); } diff --git a/backend/base/src/main/java/de/eshg/base/keycloak/EmployeeKeycloakProvisioning.java b/backend/base/src/main/java/de/eshg/base/keycloak/EmployeeKeycloakProvisioning.java index 92afe58b1b489a8c1fc8eaf6d013ef1b18835bf7..85d1aa4cb3f62e6644167ac419548cb6afe4f261 100644 --- a/backend/base/src/main/java/de/eshg/base/keycloak/EmployeeKeycloakProvisioning.java +++ b/backend/base/src/main/java/de/eshg/base/keycloak/EmployeeKeycloakProvisioning.java @@ -43,6 +43,7 @@ public class EmployeeKeycloakProvisioning extends KeycloakProvisioning<EmployeeK public static final String BEAN_NAME = "employeeKeycloakProvisioning"; public static final String CUSTOM_BROWSER_FLOW_ALIAS = "custom browser flow"; private final URI synapseUrl; + private final URI synapseInternalUrl; private final String synapseClientSecret; public EmployeeKeycloakProvisioning( @@ -50,6 +51,7 @@ public class EmployeeKeycloakProvisioning extends KeycloakProvisioning<EmployeeK KeycloakProperties keycloakProperties, @Value("${eshg.employee-portal.reverse-proxy.url}") URI reverseProxyUrl, @Value("${eshg.synapse.url:}") URI synapseUrl, + @Value("${eshg.synapse.internal.url:}") URI synapseInternalUrl, @Value("${eshg.synapse.client.secret:}") String synapseClientSecret, MutexService mutexService) { super( @@ -59,6 +61,7 @@ public class EmployeeKeycloakProvisioning extends KeycloakProvisioning<EmployeeK keycloakProperties.employeeRealm(), mutexService); this.synapseUrl = synapseUrl; + this.synapseInternalUrl = synapseInternalUrl; this.synapseClientSecret = synapseClientSecret; } @@ -197,7 +200,7 @@ public class EmployeeKeycloakProvisioning extends KeycloakProvisioning<EmployeeK getClientRepresentationAttributes( Map.of( "backchannel.logout.url", - UriComponentsBuilder.fromUri(synapseUrl) + UriComponentsBuilder.fromUri(synapseInternalUrl) .path("/_synapse/client/oidc/backchannel_logout") .toUriString(), "backchannel.logout.revoke.offline.tokens", 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 a87d01ad4ae1f76dd90fb8b853a4905f31aead37..f69518f5c6ba742d07655671a5ebaebbf5bcb34e 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 @@ -13,7 +13,7 @@ import static de.eshg.lib.keycloak.EmployeePermissionRole.BASE_GDPR_VALIDATION_T import static de.eshg.lib.keycloak.EmployeePermissionRole.BASE_MAIL_SEND; import static de.eshg.lib.keycloak.EmployeePermissionRole.BASE_PERSONS_DELETE; import static de.eshg.lib.keycloak.EmployeePermissionRole.STANDARD_EMPLOYEE; -import static de.eshg.lib.keycloak.EmployeePermissionRole.STATISTICS_STATISTICS_WRITE; +import static de.eshg.lib.keycloak.EmployeePermissionRole.STATISTICS_STATISTICS_TECHNICAL_USER; import de.eshg.lib.keycloak.EmployeePermissionRole; import java.util.List; @@ -31,7 +31,7 @@ public enum ModuleClient { "measles-protection", List.of(BASE_MAIL_SEND, BASE_PERSONS_DELETE, BASE_FACILITIES_DELETE)), SCHOOL_ENTRY( "school-entry", List.of(BASE_MAIL_SEND, BASE_PERSONS_DELETE, BASE_FACILITIES_DELETE)), - STATISTICS("statistics", List.of(STATISTICS_STATISTICS_WRITE)), + STATISTICS("statistics", List.of(STATISTICS_STATISTICS_TECHNICAL_USER)), TRAVEL_MEDICINE( "travel-medicine", List.of( @@ -40,7 +40,12 @@ public enum ModuleClient { BASE_PERSONS_DELETE, BASE_FACILITIES_DELETE)), STI_PROTECTION( - "sti-protection", List.of(BASE_MAIL_SEND, BASE_PERSONS_DELETE, BASE_FACILITIES_DELETE)), + "sti-protection", + List.of( + BASE_MAIL_SEND, + BASE_PERSONS_DELETE, + BASE_FACILITIES_DELETE, + BASE_ACCESS_CODE_USER_ADMIN)), MEDICAL_REGISTRY( "medical-registry", List.of(BASE_MAIL_SEND, BASE_PERSONS_DELETE, BASE_FACILITIES_DELETE)), DENTAL("dental", List.of(BASE_MAIL_SEND, BASE_PERSONS_DELETE, BASE_FACILITIES_DELETE)), diff --git a/backend/base/src/main/java/de/eshg/base/mail/MailController.java b/backend/base/src/main/java/de/eshg/base/mail/MailController.java index 45daef57acd9eb012634ccd40ddab8a44e66a0a0..c93654db60d89dad368d7949950afb373751c24e 100644 --- a/backend/base/src/main/java/de/eshg/base/mail/MailController.java +++ b/backend/base/src/main/java/de/eshg/base/mail/MailController.java @@ -5,6 +5,8 @@ package de.eshg.base.mail; +import com.google.common.base.Supplier; +import com.google.common.base.Suppliers; import de.eshg.base.department.DepartmentConfiguration; import de.eshg.base.user.UserService; import de.eshg.lib.auditlog.AuditLogger; @@ -13,11 +15,19 @@ import de.eshg.rest.service.security.CurrentUserHelper; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.Calendar; import java.util.LinkedHashMap; import java.util.Map; +import org.apache.batik.transcoder.TranscoderException; +import org.apache.batik.transcoder.TranscoderInput; +import org.apache.batik.transcoder.TranscoderOutput; +import org.apache.batik.transcoder.image.PNGTranscoder; import org.keycloak.representations.idm.UserRepresentation; import org.springframework.beans.factory.annotation.Value; -import org.springframework.mail.SimpleMailMessage; +import org.springframework.core.io.Resource; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.web.bind.annotation.RestController; @@ -34,6 +44,8 @@ public class MailController implements MailApi { private final JavaMailSender mailSender; private final TemplateEngine templateEngine; private final String defaultFrom; + private final String citizenPortalUrl; + private final Supplier<String> logoBase64PngSupplier; public MailController( AuditLogger auditLogger, @@ -41,23 +53,34 @@ public class MailController implements MailApi { DepartmentConfiguration departmentConfiguration, JavaMailSender mailSender, TemplateEngine templateEngine, - @Value("${eshg.mail.noreply}") String defaultFrom) { + @Value("${eshg.mail.noreply}") String defaultFrom, + @Value("${eshg.citizen-portal.reverse-proxy.url}") String citizenPortalUrl) { this.auditLogger = auditLogger; this.userService = userService; this.departmentConfiguration = departmentConfiguration; this.mailSender = mailSender; this.templateEngine = templateEngine; this.defaultFrom = defaultFrom; + this.citizenPortalUrl = citizenPortalUrl; + logoBase64PngSupplier = Suppliers.memoize(() -> svgToBase64Png(departmentConfiguration.logo())); } @Override public void sendEmail(SendEmailRequest request) { - SimpleMailMessage message = new SimpleMailMessage(); - message.setFrom(request.from() != null ? request.from() : defaultFrom); - message.setTo(request.to()); - message.setSubject(request.subject()); - message.setText(request.text()); - mailSender.send(message); + MimeMessage mimeMessage = mailSender.createMimeMessage(); + try { + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + helper.setFrom(request.from() != null ? request.from() : defaultFrom); + helper.setTo(request.to()); + helper.setSubject(request.subject()); + switch (request.type()) { + case PLAIN_TEXT -> helper.setText(request.text(), false); + case HTML -> helper.setText(applyHtmlTemplate(request.subject(), request.text()), true); + } + } catch (MessagingException e) { + throw new RuntimeException("Could not create and send email.", e); + } + mailSender.send(mimeMessage); writeAuditLog(Map.of("Typ", "Klartext")); } @@ -97,6 +120,19 @@ public class MailController implements MailApi { } } + String applyHtmlTemplate(String subject, String content) { + Context context = new Context(); + context.setVariable("title", subject); + context.setVariable("content", content); + context.setVariable("departmentName", departmentConfiguration.name()); + context.setVariable("departmentCity", departmentConfiguration.city()); + context.setVariable("logoBase64Png", logoBase64PngSupplier.get()); + context.setVariable("citizenPortalUrl", citizenPortalUrl); + context.setVariable("year", Calendar.getInstance().get(Calendar.YEAR)); + + return templateEngine.process("citizen-email", context); + } + private void writeAuditLog(Map<String, String> attributes) { attributes = new LinkedHashMap<>(attributes); attributes.put( @@ -104,4 +140,16 @@ public class MailController implements MailApi { auditLogger.log("Mail", "Versand", attributes); } + + public static String svgToBase64Png(Resource svg) { + try (ByteArrayOutputStream pngStream = new ByteArrayOutputStream()) { + TranscoderInput transcoderInput = new TranscoderInput(svg.getInputStream()); + TranscoderOutput transcoderOutput = new TranscoderOutput(pngStream); + PNGTranscoder pngTranscoder = new PNGTranscoder(); + pngTranscoder.transcode(transcoderInput, transcoderOutput); + return Base64.getEncoder().encodeToString(pngStream.toByteArray()); + } catch (TranscoderException | IOException e) { + throw new RuntimeException(e); + } + } } diff --git a/backend/base/src/main/java/de/eshg/base/muk/MukFacilityLinkController.java b/backend/base/src/main/java/de/eshg/base/muk/MukFacilityLinkController.java index 6fde78fa911bcc4e678b7ab4272efcb3e3482db9..c2898aec620a2f0fc5568a8adf2785ff5df7ef5d 100644 --- a/backend/base/src/main/java/de/eshg/base/muk/MukFacilityLinkController.java +++ b/backend/base/src/main/java/de/eshg/base/muk/MukFacilityLinkController.java @@ -51,7 +51,7 @@ public class MukFacilityLinkController implements MukFacilityLinkApi { } @Override - @Transactional + @Transactional(readOnly = true) public GetReferenceFacilityResponse getReferenceFacilityLinkedToMukSelfUser() { featureToggle.assertNewFeatureIsEnabled(BaseFeature.MUK_FACILITY_LINK); diff --git a/backend/base/src/main/java/de/eshg/base/notification/AuditLogNotificationJob.java b/backend/base/src/main/java/de/eshg/base/notification/AuditLogNotificationJob.java index 149698ebc2a83557ee5eebc9e2812d1dcb3b846d..315dbaea0317bacdd4ad1da371745efbb0f65095 100644 --- a/backend/base/src/main/java/de/eshg/base/notification/AuditLogNotificationJob.java +++ b/backend/base/src/main/java/de/eshg/base/notification/AuditLogNotificationJob.java @@ -6,6 +6,8 @@ package de.eshg.base.notification; import de.eshg.lib.rest.oauth.client.commons.ModuleClientAuthenticator; +import net.javacrumbs.shedlock.core.LockAssert; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -23,7 +25,11 @@ public class AuditLogNotificationJob { } @Scheduled(cron = "${eshg.base.auditlog.notification.schedule:@daily}") + @SchedulerLock( + name = "BaseAuditLogNotificationJob", + lockAtMostFor = "${eshg.base.auditlog.notification.lock-at-most-for:23h}") public void run() { + LockAssert.assertLocked(); moduleClientAuthenticator.doWithModuleClientAuthentication( auditLogNotificationService::sendNotifications); } diff --git a/backend/base/src/main/java/de/eshg/base/spring/config/BaseInternalSecurityConfig.java b/backend/base/src/main/java/de/eshg/base/spring/config/BaseInternalSecurityConfig.java index a3dfd6cc2cc4a942e0032834dcc27a7c456ebe4a..32338803783c86551838ea233ee7ced63c406b77 100644 --- a/backend/base/src/main/java/de/eshg/base/spring/config/BaseInternalSecurityConfig.java +++ b/backend/base/src/main/java/de/eshg/base/spring/config/BaseInternalSecurityConfig.java @@ -58,9 +58,12 @@ public class BaseInternalSecurityConfig { auth.requestMatchers(GET, StatisticsApi.BASE_URL + "/**") .hasAnyRole( EmployeePermissionRole.STATISTICS_STATISTICS_READ.name(), - EmployeePermissionRole.STATISTICS_STATISTICS_WRITE.name()); - auth.requestMatchers(POST, StatisticsApi.BASE_URL + "/**") + EmployeePermissionRole.STATISTICS_STATISTICS_WRITE.name(), + EmployeePermissionRole.STATISTICS_STATISTICS_TECHNICAL_USER.name()); + auth.requestMatchers(POST, StatisticsApi.BASE_URL + "/data-table-header/**") .hasRole(EmployeePermissionRole.STATISTICS_STATISTICS_WRITE.name()); + auth.requestMatchers(POST, StatisticsApi.BASE_URL + "/specific-data/**") + .hasRole(EmployeePermissionRole.STATISTICS_STATISTICS_TECHNICAL_USER.name()); auth.requestMatchers(StreetApi.BASE_URL + "/**") .hasRole(EmployeePermissionRole.STANDARD_EMPLOYEE.name()); }; diff --git a/backend/base/src/main/java/de/eshg/base/statistics/StatisticsController.java b/backend/base/src/main/java/de/eshg/base/statistics/StatisticsController.java index 88ed25ffaa1e6b9f976c7a9553333461be888415..495394464b52909e1a1406f809bd5064fd1b87d1 100644 --- a/backend/base/src/main/java/de/eshg/base/statistics/StatisticsController.java +++ b/backend/base/src/main/java/de/eshg/base/statistics/StatisticsController.java @@ -5,6 +5,8 @@ package de.eshg.base.statistics; +import static java.util.Collections.emptyList; + import de.eshg.base.address.persistence.embeddable.EmbeddableDomesticAddress; import de.eshg.base.centralfile.persistence.entity.*; import de.eshg.base.centralfile.persistence.repository.FacilityRepository; @@ -19,6 +21,8 @@ import de.eshg.base.statistics.api.BaseDataTableHeader; import de.eshg.base.statistics.api.GetBaseDataSourcesResponse; import de.eshg.base.statistics.api.GetBaseStatisticsDataRequest; import de.eshg.base.statistics.api.GetBaseStatisticsDataResponse; +import de.eshg.base.statistics.api.GetBaseStatisticsDataTableHeaderRequest; +import de.eshg.base.statistics.api.GetBaseStatisticsDataTableHeaderResponse; import de.eshg.base.statistics.api.SubjectType; import de.eshg.base.statistics.options.GenderOptions; import de.eshg.base.street.DistrictDto; @@ -33,10 +37,10 @@ import de.eshg.rest.service.error.BadRequestException; import io.swagger.v3.oas.annotations.Hidden; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.stream.Stream; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.RestController; @@ -91,41 +95,54 @@ public class StatisticsController implements BaseStatisticsApi { DataPrivacyCategory.QUASI_IDENTIFYING); } + @Override + @Transactional(readOnly = true) + public GetBaseStatisticsDataTableHeaderResponse getDataTableHeader( + GetBaseStatisticsDataTableHeaderRequest getDataTableHeaderRequest) { + List<String> attributeCodes = getDataTableHeaderRequest.attributeCodes(); + GetBaseStatisticsDataResponse dataResponse = + switch (getSubjectType(getDataTableHeaderRequest.dataSourceName())) { + case PERSON -> getPersonFileStateResponse(attributeCodes, emptyList()); + case FACILITY -> getFacilityFileStateResponse(attributeCodes, emptyList()); + case CONTACT -> getContactResponse(attributeCodes, emptyList()); + }; + return new GetBaseStatisticsDataTableHeaderResponse(dataResponse.dataTableHeader()); + } + @Override @Transactional(readOnly = true) public GetBaseStatisticsDataResponse getSpecificData( GetBaseStatisticsDataRequest getSpecificDataRequest) { - SubjectType subjectType = - Arrays.stream(SubjectType.values()) - .filter(sT -> sT.name().equals(getSpecificDataRequest.dataSourceName())) - .findFirst() - .orElseThrow( - () -> - new BadRequestException( - "Data source with name '%s' not found" - .formatted(getSpecificDataRequest.dataSourceName()))); - - return switch (subjectType) { - case PERSON -> getPersonFileStateResponse(getSpecificDataRequest); - case FACILITY -> getFacilityFileStateResponse(getSpecificDataRequest); - case CONTACT -> getContactResponse(getSpecificDataRequest); + List<String> attributeCodes = getSpecificDataRequest.attributeCodes(); + List<UUID> baseIds = getSpecificDataRequest.baseIds(); + return switch (getSubjectType(getSpecificDataRequest.dataSourceName())) { + case PERSON -> getPersonFileStateResponse(attributeCodes, baseIds); + case FACILITY -> getFacilityFileStateResponse(attributeCodes, baseIds); + case CONTACT -> getContactResponse(attributeCodes, baseIds); }; } + private static SubjectType getSubjectType(String dataSourceName) { + return Arrays.stream(SubjectType.values()) + .filter(sT -> sT.name().equals(dataSourceName)) + .findFirst() + .orElseThrow( + () -> + new BadRequestException( + "Data source with name '%s' not found".formatted(dataSourceName))); + } + private GetBaseStatisticsDataResponse getPersonFileStateResponse( - GetBaseStatisticsDataRequest getSpecificDataRequest) { - List<CommonAttribute> relevantCommonAttributes = - getRelevantPersonAttributes(getSpecificDataRequest.attributeCodes()); + List<String> attributeCodes, List<UUID> centralFileIds) { + List<CommonAttribute> relevantCommonAttributes = getRelevantPersonAttributes(attributeCodes); if (relevantCommonAttributes.isEmpty()) { - return new GetBaseStatisticsDataResponse( - new BaseDataTableHeader(Collections.emptyList()), null); + return new GetBaseStatisticsDataResponse(new BaseDataTableHeader(emptyList()), null); } List<BaseAttribute> attributes = getAttributes(relevantCommonAttributes, SubjectType.PERSON); List<Person> persons = - personRepository.findAllByExternalIdInAndReferencePersonIsNotNullOrderById( - getSpecificDataRequest.centralFileIds()); + personRepository.findAllByExternalIdInAndReferencePersonIsNotNullOrderById(centralFileIds); List<DataRow> dataRows = persons.stream().map(person -> createDataRow(person, relevantCommonAttributes)).toList(); @@ -297,18 +314,16 @@ public class StatisticsController implements BaseStatisticsApi { } private GetBaseStatisticsDataResponse getFacilityFileStateResponse( - GetBaseStatisticsDataRequest getSpecificDataRequest) { - List<AddressAttribute> relevantAddressAttributes = - getRelevantAddressAttributes(getSpecificDataRequest.attributeCodes()); + List<String> attributeCodes, List<UUID> centralFileIds) { + List<AddressAttribute> relevantAddressAttributes = getRelevantAddressAttributes(attributeCodes); if (relevantAddressAttributes.isEmpty()) { - return new GetBaseStatisticsDataResponse( - new BaseDataTableHeader(Collections.emptyList()), null); + return new GetBaseStatisticsDataResponse(new BaseDataTableHeader(emptyList()), null); } List<BaseAttribute> attributes = getAttributes(relevantAddressAttributes, SubjectType.FACILITY); List<Facility> facilities = facilityRepository.findAllByExternalIdInAndReferenceFacilityIsNotNullOrderById( - getSpecificDataRequest.centralFileIds()); + centralFileIds); List<DataRow> dataRows = facilities.stream() .map(facility -> createDataRow(facility, relevantAddressAttributes)) @@ -359,17 +374,16 @@ public class StatisticsController implements BaseStatisticsApi { } private GetBaseStatisticsDataResponse getContactResponse( - GetBaseStatisticsDataRequest getSpecificDataRequest) { + List<String> attributeCodes, List<UUID> contactIds) { List<ContactAttributes> relevantContactAttributes = - getRelevantContactAttributes(getSpecificDataRequest.attributeCodes()); + getRelevantContactAttributes(attributeCodes); if (relevantContactAttributes.isEmpty()) { - return new GetBaseStatisticsDataResponse( - new BaseDataTableHeader(Collections.emptyList()), null); + return new GetBaseStatisticsDataResponse(new BaseDataTableHeader(emptyList()), null); } List<BaseAttribute> attributes = getAttributes(relevantContactAttributes, SubjectType.CONTACT); - List<Contact> contacts = contactService.findAllById(getSpecificDataRequest.centralFileIds()); + List<Contact> contacts = contactService.findAllById(contactIds); List<DataRow> dataRows = contacts.stream() .map(contact -> createDataRow(contact, relevantContactAttributes)) diff --git a/backend/base/src/main/java/de/eshg/base/testhelper/BaseDatabaseResetAction.java b/backend/base/src/main/java/de/eshg/base/testhelper/BaseDatabaseResetAction.java new file mode 100644 index 0000000000000000000000000000000000000000..e5dd45f143e721cc6394112cbcc7a5d4afc6cefc --- /dev/null +++ b/backend/base/src/main/java/de/eshg/base/testhelper/BaseDatabaseResetAction.java @@ -0,0 +1,29 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.base.testhelper; + +import de.eshg.base.icd10.persistence.entity.Icd10Code; +import de.eshg.base.icd10.persistence.entity.Icd10Group; +import de.eshg.testhelper.ConditionalOnTestHelperEnabled; +import de.eshg.testhelper.DatabaseResetAction; +import de.eshg.testhelper.DatabaseResetHelper; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@ConditionalOnTestHelperEnabled +@Component +@Order(10) +public class BaseDatabaseResetAction extends DatabaseResetAction { + + public BaseDatabaseResetAction(DatabaseResetHelper databaseResetHelper) { + super(databaseResetHelper); + } + + @Override + protected String[] getTablesToExclude() { + return new String[] {Icd10Code.TABLE_NAME, Icd10Group.TABLE_NAME}; + } +} diff --git a/backend/base/src/main/java/de/eshg/base/testhelper/BaseTestHelperResetAction.java b/backend/base/src/main/java/de/eshg/base/testhelper/BaseTestHelperResetAction.java new file mode 100644 index 0000000000000000000000000000000000000000..c2bf34fa98b4ecf546f04db56d4f4242f0ee5f4a --- /dev/null +++ b/backend/base/src/main/java/de/eshg/base/testhelper/BaseTestHelperResetAction.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.base.testhelper; + +import de.eshg.base.user.UserControllerRateLimiter; +import de.eshg.testhelper.ConditionalOnTestHelperEnabled; +import de.eshg.testhelper.TestHelperServiceResetAction; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@ConditionalOnTestHelperEnabled +@Component +@Order(50) +public class BaseTestHelperResetAction implements TestHelperServiceResetAction { + private final UserControllerRateLimiter userControllerRateLimiter; + private final Icd10CodeTestHelper icd10CodeTestHelper; + + public BaseTestHelperResetAction( + UserControllerRateLimiter userControllerRateLimiter, + Icd10CodeTestHelper icd10CodeTestHelper) { + this.userControllerRateLimiter = userControllerRateLimiter; + this.icd10CodeTestHelper = icd10CodeTestHelper; + } + + @Override + public void reset() { + this.userControllerRateLimiter.reset(); + this.icd10CodeTestHelper.repopulateIcd10CodesIfNecessary(); + } +} diff --git a/backend/base/src/main/java/de/eshg/base/testhelper/BaseTestHelperService.java b/backend/base/src/main/java/de/eshg/base/testhelper/BaseTestHelperService.java index 80c9a1b0f7d9a558503d43ccab70dc4e491c7bc4..e4fe1683ae6a4034476274eadba425492127d72e 100644 --- a/backend/base/src/main/java/de/eshg/base/testhelper/BaseTestHelperService.java +++ b/backend/base/src/main/java/de/eshg/base/testhelper/BaseTestHelperService.java @@ -14,8 +14,6 @@ import de.eshg.base.calendar.api.UserCalendar; import de.eshg.base.citizenuser.AccessCodeGenerator; import de.eshg.base.contact.api.ContactDto; import de.eshg.base.contact.api.SearchContactsResponse; -import de.eshg.base.icd10.persistence.entity.Icd10Code; -import de.eshg.base.icd10.persistence.entity.Icd10Group; import de.eshg.base.inventory.api.GetInventoryItemsResponse; import de.eshg.base.inventory.api.InventoryItemDto; import de.eshg.base.keycloak.CitizenKeycloakTestClient; @@ -29,7 +27,6 @@ import de.eshg.base.resource.api.GetResourcesResponse; import de.eshg.base.resource.api.ResourceDto; import de.eshg.base.testhelper.api.CreateCalendarTestEventsRequest; import de.eshg.base.testhelper.api.CreateCalendarTestEventsResponse; -import de.eshg.base.user.UserControllerRateLimiter; import de.eshg.base.user.api.UserDto; import de.eshg.base.user.mapper.UserMapper; import de.eshg.lib.common.TimeoutConstants; @@ -41,6 +38,7 @@ import de.eshg.testhelper.ConditionalOnTestHelperEnabled; import de.eshg.testhelper.DatabaseResetHelper; import de.eshg.testhelper.DefaultTestHelperService; import de.eshg.testhelper.ResettableProperties; +import de.eshg.testhelper.TestHelperServiceResetAction; import de.eshg.testhelper.environment.EnvironmentConfig; import de.eshg.testhelper.interception.TestRequestInterceptor; import de.eshg.testhelper.population.BasePopulator; @@ -84,7 +82,6 @@ public class BaseTestHelperService extends DefaultTestHelperService { private final HealthDepartmentContactPopulator healthDepartmentContactPopulator; private final CalendarService calendarService; - private final UserControllerRateLimiter userControllerRateLimiter; private final CalendarEventService calendarEventService; private final AccessCodeGenerator accessCodeGenerator; @@ -92,7 +89,6 @@ public class BaseTestHelperService extends DefaultTestHelperService { private final InventoryPopulator inventoryPopulator; private final ContactPopulator contactPopulator; private final SchoolContactPopulator schoolContactPopulator; - private final Icd10CodeTestHelper icd10CodeTestHelper; private final Map<UsernamePassword, AccessToken> cachedAccessTokens = new ConcurrentHashMap<>(); @@ -113,16 +109,16 @@ public class BaseTestHelperService extends DefaultTestHelperService { InventoryPopulator inventoryPopulator, ContactPopulator contactPopulator, SchoolContactPopulator schoolContactPopulator, + List<TestHelperServiceResetAction> resetActions, EnvironmentConfig environmentConfig, - HealthDepartmentContactPopulator healthDepartmentContactPopulator, - UserControllerRateLimiter userControllerRateLimiter, - Icd10CodeTestHelper icd10CodeTestHelper) { + HealthDepartmentContactPopulator healthDepartmentContactPopulator) { super( databaseResetHelper, testRequestInterceptor, clock, populators, resettableProperties, + resetActions, environmentConfig); this.calendarService = calendarService; this.calendarEventService = calendarEventService; @@ -136,20 +132,6 @@ public class BaseTestHelperService extends DefaultTestHelperService { this.accessCodeGenerator = accessCodeGenerator; this.citizenKeycloakTestProvisioning = citizenKeycloakTestProvisioning; this.healthDepartmentContactPopulator = healthDepartmentContactPopulator; - this.userControllerRateLimiter = userControllerRateLimiter; - this.icd10CodeTestHelper = icd10CodeTestHelper; - } - - @Override - public Instant reset() throws Exception { - this.userControllerRateLimiter.reset(); - this.icd10CodeTestHelper.repopulateIcd10CodesIfNecessary(); - return super.reset(); - } - - @Override - protected String[] getTablesToExclude() { - return new String[] {Icd10Code.TABLE_NAME, Icd10Group.TABLE_NAME}; } public void resetKeycloak() { diff --git a/backend/base/src/main/java/de/eshg/base/user/AddUserRequestMailJob.java b/backend/base/src/main/java/de/eshg/base/user/AddUserRequestMailJob.java index 33cd876cbdc97eb240ad2f827c099cb42914e73d..c65d97cd8b10cf92d091dd3057c8149216a9f245 100644 --- a/backend/base/src/main/java/de/eshg/base/user/AddUserRequestMailJob.java +++ b/backend/base/src/main/java/de/eshg/base/user/AddUserRequestMailJob.java @@ -5,6 +5,8 @@ package de.eshg.base.user; +import net.javacrumbs.shedlock.core.LockAssert; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -17,7 +19,11 @@ public class AddUserRequestMailJob { } @Scheduled(cron = "${de.eshg.base.user.schedule:0 * * * * *}") + @SchedulerLock( + name = "BaseAddUserRequestMailJob", + lockAtMostFor = "${de.eshg.base.user.lock-at-most-for:1h}") public void sendApprovalRequestMailRemindersIfNecessary() { + LockAssert.assertLocked(); approvalRequestMailService.sendApprovalRequestMailRemindersIfNecessary(); } } diff --git a/backend/base/src/main/java/de/eshg/base/user/mapper/UserMapper.java b/backend/base/src/main/java/de/eshg/base/user/mapper/UserMapper.java index 4b627cd8473bb10415a432f4ed223a7bcf5a9231..0aec5b0285c62f653c56b00350047adb91659a14 100644 --- a/backend/base/src/main/java/de/eshg/base/user/mapper/UserMapper.java +++ b/backend/base/src/main/java/de/eshg/base/user/mapper/UserMapper.java @@ -170,6 +170,8 @@ public class UserMapper { case STATISTICS_STATISTICS_READ -> EmployeePermissionRole.STATISTICS_STATISTICS_READ; case STATISTICS_STATISTICS_WRITE -> EmployeePermissionRole.STATISTICS_STATISTICS_WRITE; case STATISTICS_STATISTICS_ADMIN -> EmployeePermissionRole.STATISTICS_STATISTICS_ADMIN; + case STATISTICS_STATISTICS_TECHNICAL_USER -> + EmployeePermissionRole.STATISTICS_STATISTICS_TECHNICAL_USER; case BASE_MAIL_SEND -> EmployeePermissionRole.BASE_MAIL_SEND; case INBOX_PROCEDURE_WRITE -> EmployeePermissionRole.INBOX_PROCEDURE_WRITE; case PROCEDURE_ARCHIVE -> EmployeePermissionRole.PROCEDURE_ARCHIVE; @@ -240,6 +242,7 @@ public class UserMapper { case STATISTICS_STATISTICS_READ -> UserRoleDto.STATISTICS_STATISTICS_READ; case STATISTICS_STATISTICS_WRITE -> UserRoleDto.STATISTICS_STATISTICS_WRITE; case STATISTICS_STATISTICS_ADMIN -> UserRoleDto.STATISTICS_STATISTICS_ADMIN; + case STATISTICS_STATISTICS_TECHNICAL_USER -> UserRoleDto.STATISTICS_STATISTICS_TECHNICAL_USER; case SCHOOL_ENTRY_ADMIN -> UserRoleDto.SCHOOL_ENTRY_ADMIN; case AUDITLOG_FILE_SEND -> UserRoleDto.AUDITLOG_FILE_SEND; case AUDITLOG_DECRYPT_AND_ACCESS -> UserRoleDto.AUDITLOG_DECRYPT_AND_ACCESS; diff --git a/backend/base/src/main/resources/application.properties b/backend/base/src/main/resources/application.properties index 01b6ddac79031e57eb8e66658276e7c7b053fe09..cfb02259214b2014c6da9e475fe7d54af4baf202 100644 --- a/backend/base/src/main/resources/application.properties +++ b/backend/base/src/main/resources/application.properties @@ -61,6 +61,7 @@ spring.security.oauth2.client.registration.module-client.client-secret=password spring.security.oauth2.client.provider.eshg-keycloak.token-uri=${eshg.keycloak.internal.url}/realms/eshg/protocol/openid-connect/token eshg.synapse.url=http://localhost:4000/api/synapse +eshg.synapse.internal.url=http://synapse:8008 logging.level.org.zalando.logbook=TRACE eshg.servicedirectory.baseUrl=http://localhost:8083 diff --git a/backend/base/src/main/resources/templates/citizen-email.html b/backend/base/src/main/resources/templates/citizen-email.html new file mode 100644 index 0000000000000000000000000000000000000000..127a8f5cf837128a6bb6aaeaf3c84b6928fdec73 --- /dev/null +++ b/backend/base/src/main/resources/templates/citizen-email.html @@ -0,0 +1,129 @@ +<!DOCTYPE html> +<!-- + Copyright 2025 cronn GmbH + SPDX-License-Identifier: Apache-2.0 +--> + +<html xmlns:th="http://www.thymeleaf.org" lang="de"> +<head> + <meta charset="UTF-8"> + <title th:text="${title}"></title> + <style th:utext="| +a:hover { + text-decoration: underline !important; +} +@font-face { + font-family: Poppins; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url("${citizenPortalUrl}/poppins-latin-ext-400-normal.woff2") format("woff2"), url("${citizenPortalUrl}/poppins-latin-ext-400-normal.woff") format("woff"); + unicode-range: U+100-2BA, U+2BD-2C5, U+2C7-2CC, U+2CE-2D7, U+2DD-2FF, U+304, U+308, U+329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: Poppins; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url("${citizenPortalUrl}}/poppins-latin-400-normal.woff2") format("woff2"), url("${citizenPortalUrl}}/poppins-latin-400-normal.woff") format("woff"); + unicode-range: U+??, U+131, U+152-153, U+2BB-2BC, U+2C6, U+2DA, U+2DC, U+304, U+308, U+329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: Poppins; + font-style: normal; + font-display: swap; + font-weight: 600; + src: url("${citizenPortalUrl}}/poppins-latin-ext-600-normal.woff2") format("woff2"), url("${citizenPortalUrl}}/poppins-latin-ext-600-normal.woff") format("woff"); + unicode-range: U+100-2BA, U+2BD-2C5, U+2C7-2CC, U+2CE-2D7, U+2DD-2FF, U+304, U+308, U+329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: Poppins; + font-style: normal; + font-display: swap; + font-weight: 600; + src: url("${citizenPortalUrl}}/poppins-latin-600-normal.woff2") format("woff2"), url("${citizenPortalUrl}}/poppins-latin-600-normal.woff") format("woff"); + unicode-range: U+??, U+131, U+152-153, U+2BB-2BC, U+2C6, U+2DA, U+2DC, U+304, U+308, U+329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: Poppins; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url("${citizenPortalUrl}}/poppins-latin-ext-700-normal.woff2") format("woff2"), url("${citizenPortalUrl}}/poppins-latin-ext-700-normal.woff") format("woff"); + unicode-range: U+100-2BA, U+2BD-2C5, U+2C7-2CC, U+2CE-2D7, U+2DD-2FF, U+304, U+308, U+329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: Poppins; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url("${citizenPortalUrl}}/poppins-latin-700-normal.woff2") format("woff2"), url("${citizenPortalUrl}}/poppins-latin-700-normal.woff") format("woff"); + unicode-range: U+??, U+131, U+152-153, U+2BB-2BC, U+2C6, U+2DA, U+2DC, U+304, U+308, U+329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: Poppins; + font-style: normal; + font-display: swap; + font-weight: 900; + src: url("${citizenPortalUrl}}/poppins-latin-ext-900-normal.woff2") format("woff2"), url("${citizenPortalUrl}}/poppins-latin-ext-900-normal.woff") format("woff"); + unicode-range: U+100-2BA, U+2BD-2C5, U+2C7-2CC, U+2CE-2D7, U+2DD-2FF, U+304, U+308, U+329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: Poppins; + font-style: normal; + font-display: swap; + font-weight: 900; + src: url("${citizenPortalUrl}}/poppins-latin-900-normal.woff2") format("woff2"), url("${citizenPortalUrl}}/poppins-latin-900-normal.woff") format("woff"); + unicode-range: U+??, U+131, U+152-153, U+2BB-2BC, U+2C6, U+2DA, U+2DC, U+304, U+308, U+329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +|"></style> +</head> +<body + style="font-family: Poppins, Arial, sans-serif; color: #171A1C; font-size: 16px; font-weight: 400; background: white; margin: 0;"> +<div style="padding: 32px 16px;" role="banner"> + <table style="width: 100%;"> + <tr> + <td> + <h1 + style="margin: 0; background-image: linear-gradient(89.95deg, #0B9DA6 0.09%, #00B8EC 50.5%, #7FC078 99.91%); background-size: 100%; background-repeat: repeat; background-clip: text; color: transparent; text-transform: uppercase; font-weight: 900;" + > + Gesundheitsamt + </h1></td> + <td rowspan="2" style="text-align: right;"><div + role="presentation"><img th:src="|data:image/png;base64,${logoBase64Png}|" /></div></td> + </tr> + <tr> + <td><h2 + style="margin: 0; font-size: 24px; font-weight: 600;" + th:text="${departmentCity}"></h2></td> + </tr> + </table> +</div> +<div style="padding: 0 16px 32px 16px;" role="main" + th:utext="${content}"></div> +<div role="contentinfo" + style="padding: 48px 16px; background-color: #32383e; color: #ffffff;"> + <p + style="margin: 0; padding-bottom: 40px; font-size: 18px; line-height: 27px;" + th:text="|© ${departmentName} ${year}|"></p> + <div style="padding-bottom: 24px;"><a + style="color: inherit; font-weight: 700; text-decoration: none;" + th:href="${citizenPortalUrl} + '/de/impressum'">Impressum</a></div> + <div style="padding-bottom: 24px;"><a + style="color: inherit; font-weight: 700; text-decoration: none;" + th:href="${citizenPortalUrl} + '/de/datenschutz'">Datenschutzerklärung</a> + </div> + <div style="padding-bottom: 24px;"><a + style="color: inherit; font-weight: 700; text-decoration: none;" + th:href="${citizenPortalUrl} + '/de/barrierefreiheit'">Barrierefreiheit</a> + </div> + <div style="padding-bottom: 24px;"><a + style="color: inherit; font-weight: 700; text-decoration: none;" + th:href="${citizenPortalUrl} + '/de/nutzungshinweise'">Nutzungshinweise</a> + </div> + <div><a + style="color: inherit; font-weight: 700; text-decoration: none;" + th:href="${citizenPortalUrl} + '/de/kontakt'">Kontakt</a></div> +</div> +</body> +</html> diff --git a/backend/business-module-commons/src/main/java/de/eshg/rest/service/PrivacyDocumentHelper.java b/backend/business-module-commons/src/main/java/de/eshg/rest/service/PrivacyDocumentHelper.java new file mode 100644 index 0000000000000000000000000000000000000000..460070eea25f6b8b410a829b07ee9e8def5057c8 --- /dev/null +++ b/backend/business-module-commons/src/main/java/de/eshg/rest/service/PrivacyDocumentHelper.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.rest.service; + +import java.nio.charset.StandardCharsets; +import org.springframework.core.io.Resource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +public final class PrivacyDocumentHelper { + + private static final String PRIVACY_POLICY_PDF_FILENAME = "Datenschutzerklaerung.pdf"; + private static final String PRIVACY_NOTICE_PDF_FILENAME = "Datenschutz-Information.pdf"; + + private PrivacyDocumentHelper() {} + + public static ResponseEntity<Resource> privacyNoticeAttachmentResponse(Resource privacyNotice) { + return pdfAttachmentResponse(privacyNotice, PRIVACY_NOTICE_PDF_FILENAME); + } + + public static ResponseEntity<Resource> privacyPolicyAttachmentResponse(Resource privacyPolicy) { + return pdfAttachmentResponse(privacyPolicy, PRIVACY_POLICY_PDF_FILENAME); + } + + private static ResponseEntity<Resource> pdfAttachmentResponse( + Resource privacyDocument, String filename) { + return ResponseEntity.ok() + .header( + HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment() + .filename(filename, StandardCharsets.UTF_8) + .build() + .toString()) + .contentType(MediaType.APPLICATION_PDF) + .body(privacyDocument); + } +} diff --git a/backend/business-module-persistence-commons/src/main/java/de/eshg/persistence/IntentionalWritingTransaction.java b/backend/business-module-persistence-commons/src/main/java/de/eshg/persistence/IntentionalWritingTransaction.java new file mode 100644 index 0000000000000000000000000000000000000000..ea116b4cc3ef38f62dc1f4c180563ce4b867436e --- /dev/null +++ b/backend/business-module-persistence-commons/src/main/java/de/eshg/persistence/IntentionalWritingTransaction.java @@ -0,0 +1,23 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.persistence; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * This annotation is used for documentation purposes and is also leveraged in compliance tests. + * Applying this annotation to a GET endpoint explicitly indicates that it participates in a WRITING + * transaction. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface IntentionalWritingTransaction { + + String reason(); +} diff --git a/backend/chat-management/README.md b/backend/chat-management/README.md deleted file mode 100644 index 552565dc86ee388c5c4445641290caf56ef5358c..0000000000000000000000000000000000000000 --- a/backend/chat-management/README.md +++ /dev/null @@ -1,21 +0,0 @@ - -# How to get latest matrix api-docs - -It has to be generated from source: https://github.com/matrix-org/matrix-spec?tab=readme-ov-file#building-the-specification - -```bash -git clone https://github.com/matrix-org/matrix-spec.git -pip install -r ./matrix-spec/scripts/requirements.txt -python ./matrix-spec/scripts/dump-openapi.py # this will generate: ./scripts/openapi/api-docs.json -``` - -Copy `./scripts/openapi/api-docs.json` to [matrix-api-v1.9-openapi-v3.1.0.json](resources%2Fapi-docs%2Fmatrix-api%2Fmatrix-api-v1.9-openapi-v3.1.0.json) - -WARNING: current `org.openapi.generator` version `7.3.0` does not support OpenApi `v3.1.0` and fails to properly generate some endpoints. -Curated version without those failing endpoints was manually created here [matrix-api-v1.9-openapi-v3.1.0-curated.json](resources%2Fapi-docs%2Fmatrix-api%2Fmatrix-api-v1.9-openapi-v3.1.0-curated.json) - -# To generate api classes run - -```bash -./gradlew chat-management:openApiGenerate -``` diff --git a/backend/chat-management/build.gradle b/backend/chat-management/build.gradle index 675ae61785d65c6188abfc27f56f16d6f9761779..fcf76301c5326ab9768e8e318417baaf8812a69b 100644 --- a/backend/chat-management/build.gradle +++ b/backend/chat-management/build.gradle @@ -32,6 +32,12 @@ tasks.named("composeUp").configure { dependsOn project(":synapse").tasks.named("composeUp") } +evaluationDependsOn(':synapse') + +tasks.named("test") { + dependsOn project(':synapse').tasks.named("composeUp") +} + dependencyTrack { projectId = project.findProperty('dependency-track-project-id-chat-management') ?: "unspecified" } diff --git a/backend/chat-management/openApi.json b/backend/chat-management/openApi.json index 2e08a3cfca46e148e5fa021747b06a0f3466d201..20caf0c708b0ffec1b76c6b30e61cc476773e4cf 100644 --- a/backend/chat-management/openApi.json +++ b/backend/chat-management/openApi.json @@ -246,6 +246,27 @@ "tags" : [ "TestHelper" ] } }, + "/user-account/bind-keycloak-id" : { + "post" : { + "operationId" : "bindKeycloakId", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/BindKeycloakIdRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "OK" + } + }, + "tags" : [ "UserAccount" ] + } + }, "/user-settings" : { "get" : { "operationId" : "getOrCreateDefaultUserSettings", @@ -312,6 +333,15 @@ } } }, + "BindKeycloakIdRequest" : { + "required" : [ "matrixUserId" ], + "type" : "object", + "properties" : { + "matrixUserId" : { + "type" : "string" + } + } + }, "ChatFeature" : { "type" : "string", "enum" : [ "CHAT_BASE" ] @@ -461,6 +491,9 @@ "accountDeactivated" : { "type" : "boolean" }, + "accountRegistered" : { + "type" : "boolean" + }, "chatConsentAsked" : { "type" : "boolean" }, @@ -488,6 +521,9 @@ "accountDeactivated" : { "type" : "boolean" }, + "accountRegistered" : { + "type" : "boolean" + }, "chatConsentAsked" : { "type" : "boolean" }, diff --git a/backend/chat-management/src/main/java/de/eshg/chat/ChatManagementApplication.java b/backend/chat-management/src/main/java/de/eshg/chat/ChatManagementApplication.java index 889de687d3f97f718eb5d2653eaaf50a943b08eb..ead8aa6778e9bf5870b5e349c9fa55fa9839d1f6 100644 --- a/backend/chat-management/src/main/java/de/eshg/chat/ChatManagementApplication.java +++ b/backend/chat-management/src/main/java/de/eshg/chat/ChatManagementApplication.java @@ -5,17 +5,40 @@ package de.eshg.chat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; import de.eshg.rest.service.security.config.ChatManagementPublicSecurityConfig; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.client.RestTemplate; +import org.zalando.logbook.Logbook; +import org.zalando.logbook.spring.LogbookClientHttpRequestInterceptor; @SpringBootApplication @ConfigurationPropertiesScan @Import(ChatManagementPublicSecurityConfig.class) public class ChatManagementApplication { + public static final String SYNAPSE_REST_TEMPLATE = "SynapseRestTemplate"; + + @Bean(SYNAPSE_REST_TEMPLATE) + public RestTemplate synapseRestTemplate( + RestTemplateBuilder restTemplateBuilder, Logbook logbook) { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + return restTemplateBuilder + .messageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .additionalInterceptors(new LogbookClientHttpRequestInterceptor(logbook)) + .build(); + } + public static void main(String[] args) { SpringApplication.run(ChatManagementApplication.class, args); } diff --git a/backend/chat-management/src/main/java/de/eshg/chat/SynapseProperties.java b/backend/chat-management/src/main/java/de/eshg/chat/SynapseProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..94e438320a0ad86daafc14ee1f99146f587ec023 --- /dev/null +++ b/backend/chat-management/src/main/java/de/eshg/chat/SynapseProperties.java @@ -0,0 +1,26 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.chat; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.net.URI; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@ConfigurationProperties(prefix = "eshg.synapse") +@Validated +public record SynapseProperties( + @Valid SynapseInternal internal, + Duration refreshClockSkew, + @NotNull String registrationSharedSecret, + @Valid SynapseAdmin admin) { + + public record SynapseInternal(@NotNull URI url) {} + + public record SynapseAdmin(@NotNull String name, @NotNull String password) {} +} diff --git a/backend/chat-management/src/main/java/de/eshg/chat/controller/UserAccountController.java b/backend/chat-management/src/main/java/de/eshg/chat/controller/UserAccountController.java new file mode 100644 index 0000000000000000000000000000000000000000..60a81db4c05863f048703b087c5236545de9b2a0 --- /dev/null +++ b/backend/chat-management/src/main/java/de/eshg/chat/controller/UserAccountController.java @@ -0,0 +1,45 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.chat.controller; + +import de.eshg.chat.featuretoggle.ChatFeature; +import de.eshg.chat.featuretoggle.ChatFeatureToggle; +import de.eshg.chat.model.dto.BindKeycloakIdRequest; +import de.eshg.chat.service.SynapseClient; +import de.eshg.rest.service.security.config.BaseUrls; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequestMapping(path = UserAccountController.BASE_URL) +@RestController +@Tag(name = "UserAccount") +public class UserAccountController { + + public static final String BASE_URL = BaseUrls.ChatManagement.USER_ACCOUNT_CONTROLLER; + + private final ChatFeatureToggle featureToggle; + private final SynapseClient synapseClient; + + public UserAccountController(SynapseClient synapseClient, ChatFeatureToggle featureToggle) { + this.featureToggle = featureToggle; + this.synapseClient = synapseClient; + } + + @PostMapping("/bind-keycloak-id") + @Transactional + public ResponseEntity<Void> bindKeycloakId( + @RequestBody @Valid BindKeycloakIdRequest bindKeycloakIdRequest) { + featureToggle.assertNewFeatureIsEnabled(ChatFeature.CHAT_BASE); + synapseClient.bindKeycloakId(bindKeycloakIdRequest.matrixUserId()); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/chat-management/src/main/java/de/eshg/chat/controller/UserSettingsController.java b/backend/chat-management/src/main/java/de/eshg/chat/controller/UserSettingsController.java index 2bac44b3528517bf43eb94a8415ef9464cb9d36e..c052ff27db8de4600f7a3d21cb8db7949ebf5e74 100644 --- a/backend/chat-management/src/main/java/de/eshg/chat/controller/UserSettingsController.java +++ b/backend/chat-management/src/main/java/de/eshg/chat/controller/UserSettingsController.java @@ -12,6 +12,7 @@ import de.eshg.chat.featuretoggle.ChatFeatureToggle; import de.eshg.chat.model.dto.UserSettingsRequest; import de.eshg.chat.model.dto.UserSettingsResponse; import de.eshg.chat.service.UserSettingsService; +import de.eshg.persistence.IntentionalWritingTransaction; import de.eshg.rest.service.security.config.BaseUrls; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -36,6 +37,7 @@ public class UserSettingsController { @GetMapping @Transactional + @IntentionalWritingTransaction(reason = "Default settings are created if missing") public UserSettingsResponse getOrCreateDefaultUserSettings(@RequestParam @Valid String userId) { featureToggle.assertNewFeatureIsEnabled(ChatFeature.CHAT_BASE); return mapTo(userSettingsService.getOrCreateDefaultSettings(userId)); diff --git a/backend/chat-management/src/main/java/de/eshg/chat/domain/model/UserSettings.java b/backend/chat-management/src/main/java/de/eshg/chat/domain/model/UserSettings.java index 7071331db2c20d3119775541fd9d067fb305b1bf..7cb0c320fcb8ab92664a621d32efdda78d05aaef 100644 --- a/backend/chat-management/src/main/java/de/eshg/chat/domain/model/UserSettings.java +++ b/backend/chat-management/src/main/java/de/eshg/chat/domain/model/UserSettings.java @@ -17,6 +17,7 @@ public class UserSettings { @Id private String userId; private Boolean chatConsentAsked = false; + private Boolean accountRegistered = false; private Boolean chatUsageEnabled = false; private Boolean sharePresence = true; private Boolean showTypingNotification = true; @@ -85,4 +86,13 @@ public class UserSettings { this.accountDeactivated = accountDeactivated; return this; } + + public Boolean getAccountRegistered() { + return accountRegistered; + } + + public UserSettings accountRegistered(Boolean accountRegistered) { + this.accountRegistered = accountRegistered; + return this; + } } diff --git a/backend/chat-management/src/main/java/de/eshg/chat/model/dto/BindKeycloakIdRequest.java b/backend/chat-management/src/main/java/de/eshg/chat/model/dto/BindKeycloakIdRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..8c5e3394d755f3094454fe3c5c2687fbb2fd5272 --- /dev/null +++ b/backend/chat-management/src/main/java/de/eshg/chat/model/dto/BindKeycloakIdRequest.java @@ -0,0 +1,10 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.chat.model.dto; + +import jakarta.validation.constraints.NotNull; + +public record BindKeycloakIdRequest(@NotNull String matrixUserId) {} diff --git a/backend/chat-management/src/main/java/de/eshg/chat/model/dto/UserSettingsRequest.java b/backend/chat-management/src/main/java/de/eshg/chat/model/dto/UserSettingsRequest.java index a9edd1135ad6603fe07398b89fe06c973f0dc45e..f9a4f56923b3b0ae0945993624048fec8e3d50f1 100644 --- a/backend/chat-management/src/main/java/de/eshg/chat/model/dto/UserSettingsRequest.java +++ b/backend/chat-management/src/main/java/de/eshg/chat/model/dto/UserSettingsRequest.java @@ -14,4 +14,5 @@ public record UserSettingsRequest( Boolean sharePresence, Boolean showReadConfirmation, Boolean showTypingNotification, - Boolean accountDeactivated) {} + Boolean accountDeactivated, + Boolean accountRegistered) {} diff --git a/backend/chat-management/src/main/java/de/eshg/chat/model/dto/UserSettingsResponse.java b/backend/chat-management/src/main/java/de/eshg/chat/model/dto/UserSettingsResponse.java index e779a3dd8791ed79b1c7d0e56f6d9152db85a982..0579d4ec9d9d40f547be284add73b0c203ca940e 100644 --- a/backend/chat-management/src/main/java/de/eshg/chat/model/dto/UserSettingsResponse.java +++ b/backend/chat-management/src/main/java/de/eshg/chat/model/dto/UserSettingsResponse.java @@ -14,4 +14,5 @@ public record UserSettingsResponse( Boolean showTypingNotification, Boolean chatConsentAsked, Boolean showReadConfirmation, - Boolean accountDeactivated) {} + Boolean accountDeactivated, + Boolean accountRegistered) {} diff --git a/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/AccessToken.java b/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/AccessToken.java new file mode 100644 index 0000000000000000000000000000000000000000..108498e276a9c9764f4935d7a620aaf3f6639f88 --- /dev/null +++ b/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/AccessToken.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.chat.model.synapse; + +import java.time.Instant; + +public class AccessToken { + + private String accessToken; + private String refreshToken; + private Instant tokenExpirationTime; + + public String getAccessToken() { + return accessToken; + } + + public AccessToken accessToken(String accessToken) { + this.accessToken = accessToken; + return this; + } + + public String getRefreshToken() { + return refreshToken; + } + + public AccessToken refreshToken(String refreshToken) { + this.refreshToken = refreshToken; + return this; + } + + public Instant getTokenExpirationTime() { + return tokenExpirationTime; + } + + public AccessToken tokenExpirationTime(Instant tokenExpirationTime) { + this.tokenExpirationTime = tokenExpirationTime; + return this; + } +} diff --git a/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/AddExternalIdRequest.java b/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/AddExternalIdRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..e5141d14bccd8d877db97e63205c4fef19fa1a93 --- /dev/null +++ b/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/AddExternalIdRequest.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.chat.model.synapse; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public class AddExternalIdRequest { + + @JsonProperty("external_ids") + List<ExternalIdMapping> externalIds; + + public List<ExternalIdMapping> getExternalIds() { + return externalIds; + } + + public AddExternalIdRequest externalIds(List<ExternalIdMapping> externalIds) { + this.externalIds = externalIds; + return this; + } +} diff --git a/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/ExternalIdMapping.java b/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/ExternalIdMapping.java new file mode 100644 index 0000000000000000000000000000000000000000..b69eb6b76b5be57f2d6aabb5c847cf90a79c282c --- /dev/null +++ b/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/ExternalIdMapping.java @@ -0,0 +1,35 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.chat.model.synapse; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ExternalIdMapping { + + @JsonProperty("auth_provider") + private String authProvider; + + @JsonProperty("external_id") + private String externalId; + + public String getAuthProvider() { + return authProvider; + } + + public ExternalIdMapping authProvider(String authProvider) { + this.authProvider = authProvider; + return this; + } + + public String getExternalId() { + return externalId; + } + + public ExternalIdMapping externalId(String externalId) { + this.externalId = externalId; + return this; + } +} diff --git a/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/GetAccessTokenRequest.java b/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/GetAccessTokenRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..cc10afc8b9cb775646cc52c969dd4d9015467ab3 --- /dev/null +++ b/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/GetAccessTokenRequest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.chat.model.synapse; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class GetAccessTokenRequest { + + @JsonProperty("type") + private String type; + + @JsonProperty("user") + private String user; + + @JsonProperty("password") + private String password; + + @JsonProperty("refresh_token") + private boolean refreshToken; + + public String getType() { + return type; + } + + public GetAccessTokenRequest type(String type) { + this.type = type; + return this; + } + + public String getUser() { + return user; + } + + public GetAccessTokenRequest user(String user) { + this.user = user; + return this; + } + + public String getPassword() { + return password; + } + + public GetAccessTokenRequest password(String password) { + this.password = password; + return this; + } + + public boolean isRefreshToken() { + return refreshToken; + } + + public GetAccessTokenRequest refreshToken(boolean refreshToken) { + this.refreshToken = refreshToken; + return this; + } +} diff --git a/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/GetAccessTokenResponse.java b/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/GetAccessTokenResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..fef691ed33646d396e0585fcdd403a7d0dbdcf52 --- /dev/null +++ b/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/GetAccessTokenResponse.java @@ -0,0 +1,83 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.chat.model.synapse; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class GetAccessTokenResponse { + + @JsonProperty("user_id") + private String userId; + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("home_server") + private String homeServer; + + @JsonProperty("device_id") + private String deviceId; + + @JsonProperty("expires_in_ms") + private Long expiresInMs; + + @JsonProperty("refresh_token") + private String refreshToken; + + public String getUserId() { + return userId; + } + + public GetAccessTokenResponse userId(String userId) { + this.userId = userId; + return this; + } + + public String getAccessToken() { + return accessToken; + } + + public GetAccessTokenResponse accessToken(String accessToken) { + this.accessToken = accessToken; + return this; + } + + public String getHomeServer() { + return homeServer; + } + + public GetAccessTokenResponse homeServer(String homeServer) { + this.homeServer = homeServer; + return this; + } + + public String getDeviceId() { + return deviceId; + } + + public GetAccessTokenResponse deviceId(String deviceId) { + this.deviceId = deviceId; + return this; + } + + public Long getExpiresInMs() { + return expiresInMs; + } + + public GetAccessTokenResponse expiresInMs(Long expiresInMs) { + this.expiresInMs = expiresInMs; + return this; + } + + public String getRefreshToken() { + return refreshToken; + } + + public GetAccessTokenResponse refreshToken(String refreshToken) { + this.refreshToken = refreshToken; + return this; + } +} diff --git a/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/RefreshTokenRequest.java b/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/RefreshTokenRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..3e44d713ae8919a635cb82b7cbc6a0bbf2866e52 --- /dev/null +++ b/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/RefreshTokenRequest.java @@ -0,0 +1,23 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.chat.model.synapse; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class RefreshTokenRequest { + + @JsonProperty("refresh_token") + private String refreshToken; + + public String getRefreshToken() { + return refreshToken; + } + + public RefreshTokenRequest refreshToken(String refreshToken) { + this.refreshToken = refreshToken; + return this; + } +} diff --git a/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/RefreshTokenResponse.java b/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/RefreshTokenResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..7d9cf37272b662c116dc7b96e96cb833d1e6f2ce --- /dev/null +++ b/backend/chat-management/src/main/java/de/eshg/chat/model/synapse/RefreshTokenResponse.java @@ -0,0 +1,47 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.chat.model.synapse; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class RefreshTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("expires_in_ms") + private Long expiresInMs; + + @JsonProperty("refresh_token") + private String refreshToken; + + public String getAccessToken() { + return accessToken; + } + + public RefreshTokenResponse accessToken(String accessToken) { + this.accessToken = accessToken; + return this; + } + + public Long getExpiresInMs() { + return expiresInMs; + } + + public RefreshTokenResponse expiresInMs(Long expiresInMs) { + this.expiresInMs = expiresInMs; + return this; + } + + public String getRefreshToken() { + return refreshToken; + } + + public RefreshTokenResponse refreshToken(String refreshToken) { + this.refreshToken = refreshToken; + return this; + } +} diff --git a/backend/chat-management/src/main/java/de/eshg/chat/service/SynapseAuthenticationService.java b/backend/chat-management/src/main/java/de/eshg/chat/service/SynapseAuthenticationService.java new file mode 100644 index 0000000000000000000000000000000000000000..15146684139d7fbaa40b9476c05587bf344a6e9e --- /dev/null +++ b/backend/chat-management/src/main/java/de/eshg/chat/service/SynapseAuthenticationService.java @@ -0,0 +1,107 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.chat.service; + +import static de.eshg.chat.ChatManagementApplication.SYNAPSE_REST_TEMPLATE; +import static de.eshg.chat.service.RestUtils.getResponseBody; + +import de.eshg.chat.SynapseProperties; +import de.eshg.chat.model.synapse.*; +import de.eshg.rest.service.error.BadRequestException; +import java.time.Clock; +import java.time.Instant; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Component +public class SynapseAuthenticationService { + + private static final Logger log = LoggerFactory.getLogger(SynapseAuthenticationService.class); + + private final SynapseProperties synapseProperties; + private final RestTemplate restTemplate; + private final Clock clock; + + private AccessToken accessToken; + + public SynapseAuthenticationService( + @Autowired SynapseProperties synapseProperties, + @Autowired @Qualifier(SYNAPSE_REST_TEMPLATE) RestTemplate synapseRestTemplate, + @Autowired Clock clock) { + this.synapseProperties = synapseProperties; + this.restTemplate = synapseRestTemplate; + this.clock = clock; + } + + public String getAccessToken() { + if (accessToken == null) { + accessToken = getNewAccessToken(); + } else if (accessTokenExpired(accessToken)) { + accessToken = refreshAccessToken(accessToken); + } + return accessToken.getAccessToken(); + } + + private boolean accessTokenExpired(AccessToken accessToken) { + return Instant.now(clock) + .isAfter( + accessToken + .getTokenExpirationTime() + .minus( + synapseProperties + .refreshClockSkew())); // Refresh token if is about to expire in <1 minute + } + + private AccessToken getNewAccessToken() { + try { + ResponseEntity<GetAccessTokenResponse> response = + restTemplate.postForEntity( + synapseProperties.internal().url() + "/_matrix/client/r0/login", + new GetAccessTokenRequest() + .type("m.login.password") + .user(synapseProperties.admin().name()) + .password(synapseProperties.admin().password()) + .refreshToken(true), + GetAccessTokenResponse.class); + + GetAccessTokenResponse body = getResponseBody(response); + return new AccessToken() + .accessToken(body.getAccessToken()) + .refreshToken(body.getRefreshToken()) + .tokenExpirationTime(Instant.now(clock).plusMillis(body.getExpiresInMs())); + + } catch (Exception ex) { + log.error("Failed to obtain token from Synapse server.", ex); + throw new BadRequestException("Failed to obtain token from Synapse server.", ex.getMessage()); + } + } + + private AccessToken refreshAccessToken(AccessToken accessToken) { + try { + ResponseEntity<RefreshTokenResponse> response = + restTemplate.postForEntity( + synapseProperties.internal().url() + "/_matrix/client/r0/refresh", + new RefreshTokenRequest().refreshToken(accessToken.getRefreshToken()), + RefreshTokenResponse.class); + + RefreshTokenResponse body = getResponseBody(response); + return new AccessToken() + .accessToken(body.getAccessToken()) + .refreshToken(body.getRefreshToken()) + .tokenExpirationTime(Instant.now(clock).plusMillis(body.getExpiresInMs())); + + } catch (Exception ex) { + log.error("Failed to refresh token from Synapse server.", ex); + throw new BadRequestException( + "Failed to refresh token from Synapse server.", ex.getMessage()); + } + } +} diff --git a/backend/chat-management/src/main/java/de/eshg/chat/service/SynapseClient.java b/backend/chat-management/src/main/java/de/eshg/chat/service/SynapseClient.java new file mode 100644 index 0000000000000000000000000000000000000000..269db8e554b9e58ad06db9cf50ed7075764ad110 --- /dev/null +++ b/backend/chat-management/src/main/java/de/eshg/chat/service/SynapseClient.java @@ -0,0 +1,94 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.chat.service; + +import static de.eshg.chat.ChatManagementApplication.SYNAPSE_REST_TEMPLATE; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.web.util.UriComponentsBuilder.fromPath; + +import de.eshg.chat.SynapseProperties; +import de.eshg.chat.model.synapse.*; +import de.eshg.rest.service.error.BadRequestException; +import java.util.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +public class SynapseClient { + + private static final Logger log = LoggerFactory.getLogger(SynapseClient.class); + + private final SynapseProperties synapseProperties; + + private final RestTemplate restTemplate; + + private final SynapseAuthenticationService synapseAuthService; + + private SynapseClient( + @Autowired SynapseAuthenticationService synapseAuthenticationService, + @Autowired @Qualifier(SYNAPSE_REST_TEMPLATE) RestTemplate synapseRestTemplate, + @Autowired SynapseProperties synapseProperties) { + this.restTemplate = synapseRestTemplate; + this.synapseAuthService = synapseAuthenticationService; + this.synapseProperties = synapseProperties; + } + + public void bindKeycloakId(String matrixUserId) { + try { + String keycloakUserId = extractMXIDLocalpart(matrixUserId); + + AddExternalIdRequest request = + new AddExternalIdRequest() + .externalIds( + List.of( + new ExternalIdMapping() + .externalId(keycloakUserId) + .authProvider("oidc-keycloak"))); + + restTemplate.exchange( + resolveUrl( + fromPath("/_synapse/admin/v2/users/{matrixUserId}") + .buildAndExpand(matrixUserId) + .toUriString()), + HttpMethod.PUT, + authenticatedRequest(request), + Void.class); + } catch (Exception ex) { + throw new BadRequestException(ex.getMessage()); + } + } + + private String extractMXIDLocalpart(String matrixUserId) { + return matrixUserId.substring(1).split(":")[0]; + } + + private String resolveUrl(String url) { + return synapseProperties.internal().url() + url; + } + + private <T> HttpEntity<T> authenticatedRequest() { + return authenticatedRequest(null); + } + + private <T> HttpEntity<T> authenticatedRequest(T requestBody) { + return new HttpEntity<>(requestBody, createHeaders()); + } + + private HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + synapseAuthService.getAccessToken()); + headers.set("Content-Type", APPLICATION_JSON_VALUE); + headers.set("Accept", APPLICATION_JSON_VALUE); + return headers; + } +} diff --git a/backend/chat-management/src/main/java/de/eshg/chat/service/UserSettingsService.java b/backend/chat-management/src/main/java/de/eshg/chat/service/UserSettingsService.java index 683a2d902e2fbbe54f78f4328b0d817e82fa7006..f8f23d4b5dbbe7900518bfc9a1c2ddf0e6a450b3 100644 --- a/backend/chat-management/src/main/java/de/eshg/chat/service/UserSettingsService.java +++ b/backend/chat-management/src/main/java/de/eshg/chat/service/UserSettingsService.java @@ -41,7 +41,8 @@ public class UserSettingsService { .showTypingNotification(request.showTypingNotification()) .showReadConfirmation(request.showReadConfirmation()) .chatConsentAsked(request.chatConsentAsked()) - .accountDeactivated(request.accountDeactivated()); + .accountDeactivated(request.accountDeactivated()) + .accountRegistered(request.accountRegistered()); } public static UserSettingsResponse mapTo(UserSettings userSettings) { @@ -52,7 +53,8 @@ public class UserSettingsService { userSettings.getShowTypingNotification(), userSettings.getChatConsentAsked(), userSettings.getShowReadConfirmation(), - userSettings.getAccountDeactivated()); + userSettings.getAccountDeactivated(), + userSettings.getAccountRegistered()); } private UserSettings mapOnlyNonNullFields( @@ -75,6 +77,9 @@ public class UserSettingsService { if (userSettingsRequest.accountDeactivated() != null) { userSettings.accountDeactivated(userSettingsRequest.accountDeactivated()); } + if (userSettingsRequest.accountRegistered() != null) { + userSettings.accountRegistered(userSettingsRequest.accountRegistered()); + } return userSettings; } } diff --git a/backend/chat-management/src/main/resources/application.properties b/backend/chat-management/src/main/resources/application.properties index 567923dc5ab54bf7e632e56d48149183a7df72f0..b6572af9f4bf0b249a422bd95f192cadbfe8c97e 100644 --- a/backend/chat-management/src/main/resources/application.properties +++ b/backend/chat-management/src/main/resources/application.properties @@ -1,3 +1,9 @@ +eshg.synapse.internal.url=http://${DOCKER_HOSTNAME:localhost}:8008 +eshg.synapse.refresh-clock-skew=PT1M +eshg.synapse.registration-shared-secret=k.@ukx06IL;5RcXHIo=^m4LI7lF*x-BgNegdB367MEyR@oe&~K +eshg.synapse.admin.name=admin +eshg.synapse.admin.password=admin + # Datasource spring.datasource.url=jdbc:postgresql://localhost:5441/chat_management spring.datasource.username=testuser @@ -15,3 +21,9 @@ logging.level.org.zalando.logbook=TRACE logbook.obfuscate.json-body-fields[0]=password logbook.obfuscate.json-body-fields[1]=access_token logbook.obfuscate.json-body-fields[2]=refresh_token +logbook.obfuscate.json-body-fields[3]=token +logbook.obfuscate.json-body-fields[4]=refreshToken +logbook.obfuscate.json-body-fields[5]=accessToken + +# Synapse AccessToken is not aware of test-helper clock timeline +eshg.testclock.enabled=false diff --git a/backend/chat-management/src/main/resources/migrations/0004_user_settings_add_account_registered.xml b/backend/chat-management/src/main/resources/migrations/0004_user_settings_add_account_registered.xml new file mode 100644 index 0000000000000000000000000000000000000000..38f6dc321572a6e6a5d059f061f3cb83a7e6037b --- /dev/null +++ b/backend/chat-management/src/main/resources/migrations/0004_user_settings_add_account_registered.xml @@ -0,0 +1,15 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 cronn GmbH + SPDX-License-Identifier: AGPL-3.0-only +--> + +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> + <changeSet author="GA-Lotse" id="1733937446781-1"> + <addColumn tableName="user_settings"> + <column name="account_registered" type="BOOLEAN"/> + </addColumn> + </changeSet> +</databaseChangeLog> diff --git a/backend/chat-management/src/main/resources/migrations/changelog.xml b/backend/chat-management/src/main/resources/migrations/changelog.xml index 7ddafc91e9dfd062e89de3fa308e938e16d605ab..dfe01be2b4ffd696e57f755dbaa0ce18ed23e58f 100644 --- a/backend/chat-management/src/main/resources/migrations/changelog.xml +++ b/backend/chat-management/src/main/resources/migrations/changelog.xml @@ -11,5 +11,6 @@ <include file="migrations/0001_initial.xml"/> <include file="migrations/0002_drop_default_revision_entity_created_by_not_null_contraint.xml"/> <include file="migrations/0003_user_settings_add_account_deactivated.xml"/> + <include file="migrations/0004_user_settings_add_account_registered.xml"/> </databaseChangeLog> diff --git a/backend/compliance-test/archunit_store/e2982c9c-30e7-4c9f-b4bf-d5fa6716109a b/backend/compliance-test/archunit_store/e2982c9c-30e7-4c9f-b4bf-d5fa6716109a new file mode 100644 index 0000000000000000000000000000000000000000..fe7e521fc96ed7128ecc8d25b9b25f9ba68fbc45 --- /dev/null +++ b/backend/compliance-test/archunit_store/e2982c9c-30e7-4c9f-b4bf-d5fa6716109a @@ -0,0 +1,2 @@ +Class <de.eshg.relayserver.EndpointConfiguration> is not de.eshg.lib.scheduling.spring.SchedulingConfiguration in (EndpointConfiguration.java:0) +Class <de.eshg.spatz.common.ServiceDirectoryTopologyService> is not de.eshg.lib.scheduling.spring.SchedulingConfiguration in (ServiceDirectoryTopologyService.java:0) diff --git a/backend/compliance-test/archunit_store/stored.rules b/backend/compliance-test/archunit_store/stored.rules index 72085fbc82f3a5559f3e0a793b60437990f61184..85f5cbbdab7a568c9db1bebfddf8390a1b4d2150 100644 --- a/backend/compliance-test/archunit_store/stored.rules +++ b/backend/compliance-test/archunit_store/stored.rules @@ -1,5 +1,6 @@ # -#Thu Nov 28 14:17:47 CET 2024 +#Tue Feb 04 10:18:16 CET 2025 +classes\ that\ are\ annotated\ with\ @EnableScheduling\ should\ be\ de.eshg.lib.scheduling.spring.SchedulingConfiguration=e2982c9c-30e7-4c9f-b4bf-d5fa6716109a fields\ that\ are\ declared\ in\ classes\ that\ annotated\ with\ @Entity\ or\ annotated\ with\ @MappedSuperclass\ or\ annotated\ with\ @Embeddable\ and\ are\ not\ annotated\ with\ @Transient\ and\ are\ not\ static\ should\ be\ annotated\ with\ @DataSensitivity\ or\ should\ be\ declared\ in\ classes\ that\ are\ annotated\ with\ @DataSensitivity=e77d8ad7-eae8-405c-86be-ad5ea44e0614 fields\ that\ are\ declared\ in\ classes\ that\ annotated\ with\ @Entity\ or\ annotated\ with\ @MappedSuperclass\ or\ annotated\ with\ @Embeddable\ should\ not\ declare\ insertable\=false\ in\ @Column\ /\ @JoinColumn\ definition=76ae00b4-4b81-4e06-8600-24dd9666ffa5 fields\ that\ are\ declared\ in\ classes\ that\ annotated\ with\ @Entity\ or\ annotated\ with\ @MappedSuperclass\ or\ annotated\ with\ @Embeddable\ should\ not\ declare\ updatable\=false\ in\ @Column\ /\ @JoinColumn\ definition=552ce0b9-6b94-4214-83ca-3bdc50ce9afe diff --git a/backend/dental/openApi.json b/backend/dental/openApi.json index 4c9998473728db8586a7fec365bf93dde1d4e432..ec27e31ac85816498d9eaea8b400b06969767ceb 100644 --- a/backend/dental/openApi.json +++ b/backend/dental/openApi.json @@ -3684,6 +3684,9 @@ "format" : "uuid" } }, + "dentitionType" : { + "$ref" : "#/components/schemas/DentitionType" + }, "fluoridationVarnish" : { "$ref" : "#/components/schemas/FluoridationVarnish" }, @@ -3766,6 +3769,10 @@ "propertyName" : "@type" } }, + "DentitionType" : { + "type" : "string", + "enum" : [ "PRIMARY", "MIXED", "SECONDARY" ] + }, "DetailedFacility" : { "required" : [ "facilityFileState", "facilityType" ], "type" : "object", @@ -5771,9 +5778,15 @@ } }, "ProphylaxisSessionChildExamination" : { - "required" : [ "childId", "dateOfBirth", "examinationId", "examinationVersion", "firstName", "groupName", "lastName", "previousExaminationResults" ], + "required" : [ "allFluoridationConsents", "childId", "dateOfBirth", "examinationId", "examinationVersion", "firstName", "groupName", "lastName", "previousExaminationResults" ], "type" : "object", "properties" : { + "allFluoridationConsents" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/FluoridationConsent" + } + }, "childId" : { "type" : "string", "format" : "uuid" @@ -5793,9 +5806,6 @@ "firstName" : { "type" : "string" }, - "fluoridationConsentGiven" : { - "type" : "boolean" - }, "gender" : { "$ref" : "#/components/schemas/Gender" }, @@ -5849,6 +5859,9 @@ } ] } }, + "dentitionType" : { + "$ref" : "#/components/schemas/DentitionType" + }, "fluoridationVarnish" : { "$ref" : "#/components/schemas/FluoridationVarnish" }, @@ -5924,13 +5937,16 @@ "enum" : [ "NOT_SPECIFIED", "NEUTRAL", "FEMALE", "MALE" ] }, "ScreeningExaminationResult" : { - "required" : [ "toothDiagnoses" ], + "required" : [ "dentitionType", "toothDiagnoses" ], "type" : "object", "allOf" : [ { "$ref" : "#/components/schemas/DentalExaminationResult" }, { "type" : "object", "properties" : { + "dentitionType" : { + "$ref" : "#/components/schemas/DentitionType" + }, "fluorideVarnishApplied" : { "type" : "boolean" }, @@ -5960,7 +5976,7 @@ }, "SecondaryResult" : { "type" : "string", - "enum" : [ "DA", "FA", "FIS", "ID", "INS", "LUE", "RET", "TR", "WR", "ZA" ] + "enum" : [ "DA", "FA", "FIS", "ID", "INS", "LÜ", "RET", "TR", "WR", "ZA" ] }, "SelfAssignTaskRequest" : { "required" : [ "taskVersion" ], @@ -5998,7 +6014,11 @@ "type" : "integer", "format" : "int32" }, - "previousFileStateId" : { + "previousFacilityFileStateId" : { + "type" : "string", + "format" : "uuid" + }, + "previousPersonFileStateId" : { "type" : "string", "format" : "uuid" }, @@ -6340,6 +6360,9 @@ "format" : "uuid" } }, + "dentitionType" : { + "$ref" : "#/components/schemas/DentitionType" + }, "fluoridationVarnish" : { "$ref" : "#/components/schemas/FluoridationVarnish" }, diff --git a/backend/dental/src/main/java/de/eshg/dental/ExaminationService.java b/backend/dental/src/main/java/de/eshg/dental/ExaminationService.java index ef6ecd79a4501c4472fd1234c4db662eb2300e08..48158e8fdc5103fd57d940239968da87127acc4e 100644 --- a/backend/dental/src/main/java/de/eshg/dental/ExaminationService.java +++ b/backend/dental/src/main/java/de/eshg/dental/ExaminationService.java @@ -19,6 +19,7 @@ import de.eshg.dental.domain.model.FluoridationExaminationResult; import de.eshg.dental.domain.model.ProphylaxisSession; import de.eshg.dental.domain.model.ScreeningExaminationResult; import de.eshg.dental.domain.repository.ExaminationRepository; +import de.eshg.dental.mapper.DentitionTypeMapper; import de.eshg.dental.mapper.ExaminationMapper; import de.eshg.dental.util.ChildSystemProgressEntryType; import de.eshg.dental.util.ExceptionUtil; @@ -149,6 +150,8 @@ public class ExaminationService { existingResult.setFluorideVarnishApplied(newResult.fluorideVarnishApplied()); existingResult.setOralHygieneStatus( ExaminationMapper.mapToDomain(newResult.oralHygieneStatus())); + existingResult.setDentitionType( + DentitionTypeMapper.mapToDomain(newResult.dentitionType())); existingResult.setToothDiagnoses( ExaminationMapper.mapToDomain(newResult.toothDiagnoses())); }); diff --git a/backend/dental/src/main/java/de/eshg/dental/ProphylaxisSessionController.java b/backend/dental/src/main/java/de/eshg/dental/ProphylaxisSessionController.java index eb95cdd86128df5a38f06f8dd06e6b66ffe721d6..d387386a9ccd463bf1d1ff045dab0e90564ceaea 100644 --- a/backend/dental/src/main/java/de/eshg/dental/ProphylaxisSessionController.java +++ b/backend/dental/src/main/java/de/eshg/dental/ProphylaxisSessionController.java @@ -41,22 +41,15 @@ public class ProphylaxisSessionController { public static final String BASE_URL = BaseUrls.Dental.PROPHYLAXIS_SESSION_CONTROLLER; private final ProphylaxisSessionService prophylaxisSessionService; - private final Validator validator; - public ProphylaxisSessionController( - ProphylaxisSessionService prophylaxisSessionService, Validator validator) { + public ProphylaxisSessionController(ProphylaxisSessionService prophylaxisSessionService) { this.prophylaxisSessionService = prophylaxisSessionService; - this.validator = validator; } @PostMapping @Transactional public CreateProphylaxisSessionResponse createProphylaxisSession( @Valid @RequestBody CreateProphylaxisSessionRequest request) { - validator.validateInstitution(request.institutionId()); - validator.validateAtLeastOne(request.dentistIds(), "At least one dentist is required"); - validator.validateAtLeastOne(request.zfaIds(), "At least one zfa is required"); - validator.validateTechnicalGroups(request.dentistIds(), request.zfaIds()); ProphylaxisSession prophylaxisSession = prophylaxisSessionService.createProphylaxisSession(request); return new CreateProphylaxisSessionResponse(prophylaxisSession.getExternalId()); diff --git a/backend/dental/src/main/java/de/eshg/dental/ProphylaxisSessionService.java b/backend/dental/src/main/java/de/eshg/dental/ProphylaxisSessionService.java index 53247b32d833f0841434de35948abe0c95ca84ec..2ad027c61d58b1faa6523d8746f40778044f08a6 100644 --- a/backend/dental/src/main/java/de/eshg/dental/ProphylaxisSessionService.java +++ b/backend/dental/src/main/java/de/eshg/dental/ProphylaxisSessionService.java @@ -30,6 +30,7 @@ import de.eshg.dental.domain.model.ProphylaxisSession; import de.eshg.dental.domain.repository.ChildRepository; import de.eshg.dental.domain.repository.ExaminationRepository; import de.eshg.dental.domain.repository.ProphylaxisSessionRepository; +import de.eshg.dental.mapper.DentitionTypeMapper; import de.eshg.dental.mapper.ProphylaxisSessionMapper; import de.eshg.lib.contact.ContactClient; import de.eshg.lib.procedure.domain.model.ProcedureStatus; @@ -91,6 +92,10 @@ public class ProphylaxisSessionService { } public ProphylaxisSession createProphylaxisSession(CreateProphylaxisSessionRequest request) { + validator.validateInstitution(request.institutionId()); + validator.validateTechnicalGroups(request.dentistIds(), request.zfaIds()); + validator.validateDentitionType(request.dentitionType(), request.isScreening()); + ProphylaxisSession session = new ProphylaxisSession(); mapProphylaxisSessionRequest(session, request); addExaminationsForChildren(request, session); @@ -335,6 +340,7 @@ public class ProphylaxisSessionService { mapProphylaxisSessionRequest(new ProphylaxisSession(), updateRequest)); validator.validateGroupAtInstitutionExists( persistedProphylaxisSession.getInstitutionId(), updateRequest.groupName()); + validator.validateDentitionType(updateRequest.dentitionType(), updateRequest.isScreening()); mapProphylaxisSessionRequest(persistedProphylaxisSession, updateRequest); @@ -377,6 +383,7 @@ public class ProphylaxisSessionService { session.setDateAndTime(request.dateAndTime()); session.setGroupName(request.groupName()); session.setType(ProphylaxisSessionMapper.mapToDomain(request.type())); + session.setDentitionType(DentitionTypeMapper.mapToDomain(request.dentitionType())); session.setIsScreening(request.isScreening()); session.setFluoridationVarnish( ProphylaxisSessionMapper.mapToDomain(request.fluoridationVarnish())); diff --git a/backend/dental/src/main/java/de/eshg/dental/Validator.java b/backend/dental/src/main/java/de/eshg/dental/Validator.java index 7e3d32479737c0b83776dd0bcace28f96c2c6eeb..6bbaca114b73a75ae53410e26dc74eca7f7896f2 100644 --- a/backend/dental/src/main/java/de/eshg/dental/Validator.java +++ b/backend/dental/src/main/java/de/eshg/dental/Validator.java @@ -14,6 +14,7 @@ import de.eshg.base.contact.api.InstitutionContactCategoryDto; import de.eshg.base.user.UserApi; import de.eshg.base.user.api.UserDto; import de.eshg.dental.api.ChildFilterParameters; +import de.eshg.dental.api.DentitionTypeDto; import de.eshg.dental.api.FluoridationConsentDto; import de.eshg.dental.api.ToothDiagnosisDto; import de.eshg.dental.api.ToothDto; @@ -87,12 +88,6 @@ public class Validator { } } - void validateAtLeastOne(List<UUID> ids, String message) { - if (ids == null || ids.isEmpty()) { - throw new BadRequestException(message); - } - } - void validateTechnicalGroups(List<UUID> dentistIds, List<UUID> zfaIds) { if (dentistIds != null && !dentistIds.isEmpty()) { validateTechnicalGroup(dentistIds, TechnicalGroup.DENTIST); @@ -171,4 +166,13 @@ public class Validator { property.getDisplayName())); } } + + public void validateDentitionType(DentitionTypeDto dentitionType, boolean isScreening) { + boolean hasDentitionType = dentitionType != null; + if (isScreening && !hasDentitionType) { + throw new BadRequestException("Dentition type is mandatory for screening sessions."); + } else if (!isScreening && hasDentitionType) { + throw new BadRequestException("Dentition type is not allowed for non-screening sessions."); + } + } } diff --git a/backend/dental/src/main/java/de/eshg/dental/api/CreateProphylaxisSessionRequest.java b/backend/dental/src/main/java/de/eshg/dental/api/CreateProphylaxisSessionRequest.java index d6bea009cbe6d36796b902f65ce73172708fb664..1e1603d65c5d91f90889fa0ae7eab086972ad79b 100644 --- a/backend/dental/src/main/java/de/eshg/dental/api/CreateProphylaxisSessionRequest.java +++ b/backend/dental/src/main/java/de/eshg/dental/api/CreateProphylaxisSessionRequest.java @@ -18,6 +18,7 @@ public record CreateProphylaxisSessionRequest( @NotBlank String groupName, @NotNull ProphylaxisTypeDto type, @NotNull boolean isScreening, + DentitionTypeDto dentitionType, FluoridationVarnishDto fluoridationVarnish, @NotEmpty(message = "At least one dentist is required") List<UUID> dentistIds, @NotEmpty(message = "At least one zfa is required") List<UUID> zfaIds) @@ -29,6 +30,6 @@ public record CreateProphylaxisSessionRequest( ProphylaxisTypeDto type, List<UUID> dentistIds, List<UUID> zfaIds) { - this(dateAndTime, institutionId, groupName, type, false, null, dentistIds, zfaIds); + this(dateAndTime, institutionId, groupName, type, false, null, null, dentistIds, zfaIds); } } diff --git a/backend/dental/src/main/java/de/eshg/dental/api/DentitionTypeDto.java b/backend/dental/src/main/java/de/eshg/dental/api/DentitionTypeDto.java new file mode 100644 index 0000000000000000000000000000000000000000..10cd20b9f8a25bb8ce11e435672634fb49e4c23a --- /dev/null +++ b/backend/dental/src/main/java/de/eshg/dental/api/DentitionTypeDto.java @@ -0,0 +1,15 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.dental.api; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "DentitionType") +public enum DentitionTypeDto { + PRIMARY, + MIXED, + SECONDARY +} diff --git a/backend/dental/src/main/java/de/eshg/dental/api/ProphylaxisSessionChildExaminationDto.java b/backend/dental/src/main/java/de/eshg/dental/api/ProphylaxisSessionChildExaminationDto.java index 50a44aa83bd960ef914365a355666a40f5e2cd2f..e20e9b1971f4097d41cae330ed8cfa9597b8a07e 100644 --- a/backend/dental/src/main/java/de/eshg/dental/api/ProphylaxisSessionChildExaminationDto.java +++ b/backend/dental/src/main/java/de/eshg/dental/api/ProphylaxisSessionChildExaminationDto.java @@ -24,6 +24,6 @@ public record ProphylaxisSessionChildExaminationDto( @NotNull String groupName, GenderDto gender, String note, - Boolean fluoridationConsentGiven, + @Valid @NotNull List<FluoridationConsentDto> allFluoridationConsents, @Valid ExaminationResultDto result, @Valid @NotNull List<ExaminationResultDto> previousExaminationResults) {} diff --git a/backend/dental/src/main/java/de/eshg/dental/api/ProphylaxisSessionDetailsDto.java b/backend/dental/src/main/java/de/eshg/dental/api/ProphylaxisSessionDetailsDto.java index 8276d274af053bf6132e0c0390644d641c5fb7b8..4e670d2dbacebc4c6dac1da06f4bcc82b96dc857 100644 --- a/backend/dental/src/main/java/de/eshg/dental/api/ProphylaxisSessionDetailsDto.java +++ b/backend/dental/src/main/java/de/eshg/dental/api/ProphylaxisSessionDetailsDto.java @@ -23,6 +23,7 @@ public record ProphylaxisSessionDetailsDto( @NotBlank String groupName, @NotNull ProphylaxisTypeDto type, @NotNull boolean isScreening, + DentitionTypeDto dentitionType, FluoridationVarnishDto fluoridationVarnish, @NotNull @Valid List<ProphylaxisSessionChildExaminationDto> participants, @NotEmpty @Valid List<? extends PerformingPersonDto> dentists, diff --git a/backend/dental/src/main/java/de/eshg/dental/api/ProphylaxisSessionRequest.java b/backend/dental/src/main/java/de/eshg/dental/api/ProphylaxisSessionRequest.java index f49e0e606d9042c42971415129e54eb57b76f677..deb681367d15a3e7e7a99825f862c5d1f873caf9 100644 --- a/backend/dental/src/main/java/de/eshg/dental/api/ProphylaxisSessionRequest.java +++ b/backend/dental/src/main/java/de/eshg/dental/api/ProphylaxisSessionRequest.java @@ -18,6 +18,8 @@ public interface ProphylaxisSessionRequest { ProphylaxisTypeDto type(); + DentitionTypeDto dentitionType(); + boolean isScreening(); FluoridationVarnishDto fluoridationVarnish(); diff --git a/backend/dental/src/main/java/de/eshg/dental/api/ScreeningExaminationResultDto.java b/backend/dental/src/main/java/de/eshg/dental/api/ScreeningExaminationResultDto.java index 6686a305f5b9964f31a67116f9befda4bd321a2e..85477444a2aac240f4d26b4b9706f958de0695eb 100644 --- a/backend/dental/src/main/java/de/eshg/dental/api/ScreeningExaminationResultDto.java +++ b/backend/dental/src/main/java/de/eshg/dental/api/ScreeningExaminationResultDto.java @@ -14,18 +14,21 @@ import java.util.List; public record ScreeningExaminationResultDto( Boolean fluorideVarnishApplied, OralHygieneStatusDto oralHygieneStatus, + @NotNull DentitionTypeDto dentitionType, @NotNull @Valid List<ToothDiagnosisDto> toothDiagnoses) implements ExaminationResultDto, IsFluorideVarnishApplicable { static final String SCHEMA_NAME = "ScreeningExaminationResult"; - public ScreeningExaminationResultDto() { - this(null, null, List.of()); + public ScreeningExaminationResultDto(DentitionTypeDto dentitionType) { + this(null, null, dentitionType, List.of()); } public ScreeningExaminationResultDto( - Boolean fluorideVarnishApplied, OralHygieneStatusDto oralHygieneStatus) { - this(fluorideVarnishApplied, oralHygieneStatus, List.of()); + Boolean fluorideVarnishApplied, + OralHygieneStatusDto oralHygieneStatus, + DentitionTypeDto dentitionType) { + this(fluorideVarnishApplied, oralHygieneStatus, dentitionType, List.of()); } @Override diff --git a/backend/dental/src/main/java/de/eshg/dental/api/SecondaryResultDto.java b/backend/dental/src/main/java/de/eshg/dental/api/SecondaryResultDto.java index 8f69cf32ad25a8dcff3bb3c95834724b14d22bd1..9af6461ed239360bd9b188523aa8fe29915e24dc 100644 --- a/backend/dental/src/main/java/de/eshg/dental/api/SecondaryResultDto.java +++ b/backend/dental/src/main/java/de/eshg/dental/api/SecondaryResultDto.java @@ -5,6 +5,7 @@ package de.eshg.dental.api; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; @Schema(name = "SecondaryResult") @@ -14,6 +15,7 @@ public enum SecondaryResultDto { FIS, ID, INS, + @JsonProperty("LÜ") LUE, RET, TR, diff --git a/backend/dental/src/main/java/de/eshg/dental/api/UpdateProphylaxisSessionRequest.java b/backend/dental/src/main/java/de/eshg/dental/api/UpdateProphylaxisSessionRequest.java index 5c5a0505133bdc4ba0ec49fd36965d42b8d9e95f..f9eeeafbfaf738ea3accf6509dccc6cfb37d888e 100644 --- a/backend/dental/src/main/java/de/eshg/dental/api/UpdateProphylaxisSessionRequest.java +++ b/backend/dental/src/main/java/de/eshg/dental/api/UpdateProphylaxisSessionRequest.java @@ -19,6 +19,7 @@ public record UpdateProphylaxisSessionRequest( @NotBlank String groupName, @NotNull ProphylaxisTypeDto type, @NotNull boolean isScreening, + DentitionTypeDto dentitionType, FluoridationVarnishDto fluoridationVarnish, @NotEmpty List<UUID> dentistIds, @NotEmpty List<UUID> zfaIds) @@ -31,6 +32,16 @@ public record UpdateProphylaxisSessionRequest( ProphylaxisTypeDto type, List<UUID> dentistIds, List<UUID> zfaIds) { - this(version, institutionId, dateAndTime, groupName, type, false, null, dentistIds, zfaIds); + this( + version, + institutionId, + dateAndTime, + groupName, + type, + false, + null, + null, + dentistIds, + zfaIds); } } diff --git a/backend/dental/src/main/java/de/eshg/dental/domain/model/DentitionType.java b/backend/dental/src/main/java/de/eshg/dental/domain/model/DentitionType.java new file mode 100644 index 0000000000000000000000000000000000000000..6a8685d7d93a20a1f821b47565f1eb7641f8b4b4 --- /dev/null +++ b/backend/dental/src/main/java/de/eshg/dental/domain/model/DentitionType.java @@ -0,0 +1,12 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.dental.domain.model; + +public enum DentitionType { + PRIMARY, + MIXED, + SECONDARY +} diff --git a/backend/dental/src/main/java/de/eshg/dental/domain/model/ProphylaxisSession.java b/backend/dental/src/main/java/de/eshg/dental/domain/model/ProphylaxisSession.java index 68de07c9f0bbf6f5c23bcb90095cfe20155ac409..d12a6b9e6e8e3e312b10ab023da9660809c8f9eb 100644 --- a/backend/dental/src/main/java/de/eshg/dental/domain/model/ProphylaxisSession.java +++ b/backend/dental/src/main/java/de/eshg/dental/domain/model/ProphylaxisSession.java @@ -51,6 +51,10 @@ public class ProphylaxisSession extends BaseEntityWithExternalId { @Column(nullable = false) private String groupName; + @DataSensitivity(PSEUDONYMIZED) + @JdbcType(PostgreSQLEnumJdbcType.class) + private DentitionType dentitionType; + @DataSensitivity(PSEUDONYMIZED) private boolean isScreening; @@ -121,6 +125,14 @@ public class ProphylaxisSession extends BaseEntityWithExternalId { this.groupName = groupName; } + public DentitionType getDentitionType() { + return dentitionType; + } + + public void setDentitionType(DentitionType dentitionType) { + this.dentitionType = dentitionType; + } + public boolean isScreening() { return isScreening; } diff --git a/backend/dental/src/main/java/de/eshg/dental/domain/model/ScreeningExaminationResult.java b/backend/dental/src/main/java/de/eshg/dental/domain/model/ScreeningExaminationResult.java index 517288a96f1b3306d60fef120f420bb302dbf52b..5f2da35775dfbc5e58c070344acecfcd27de1bc8 100644 --- a/backend/dental/src/main/java/de/eshg/dental/domain/model/ScreeningExaminationResult.java +++ b/backend/dental/src/main/java/de/eshg/dental/domain/model/ScreeningExaminationResult.java @@ -7,6 +7,7 @@ package de.eshg.dental.domain.model; import de.eshg.lib.common.DataSensitivity; import de.eshg.lib.common.SensitivityLevel; +import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; @@ -28,6 +29,10 @@ public class ScreeningExaminationResult extends ExaminationResult { @JdbcType(PostgreSQLEnumJdbcType.class) private OralHygieneStatus oralHygieneStatus; + @Column(nullable = false) + @JdbcType(PostgreSQLEnumJdbcType.class) + private DentitionType dentitionType; + @ElementCollection @MapKeyJdbcType(PostgreSQLEnumJdbcType.class) @MapKeyColumn(name = "tooth") @@ -43,6 +48,14 @@ public class ScreeningExaminationResult extends ExaminationResult { this.oralHygieneStatus = oralHygieneStatus; } + public DentitionType getDentitionType() { + return dentitionType; + } + + public void setDentitionType(DentitionType dentitionType) { + this.dentitionType = dentitionType; + } + public Map<Tooth, ToothDiagnosis> getToothDiagnoses() { return toothDiagnoses; } diff --git a/backend/dental/src/main/java/de/eshg/dental/mapper/ChildMapper.java b/backend/dental/src/main/java/de/eshg/dental/mapper/ChildMapper.java index 042354cad6bd2e98f36ba2ba2b69f7b64ca226b0..dda9ac22debd9efb32bd8bd5ec3373569e8cb74e 100644 --- a/backend/dental/src/main/java/de/eshg/dental/mapper/ChildMapper.java +++ b/backend/dental/src/main/java/de/eshg/dental/mapper/ChildMapper.java @@ -18,7 +18,6 @@ import de.eshg.dental.domain.model.Examination; import de.eshg.dental.domain.model.FluoridationConsent; import de.eshg.lib.procedure.mapping.ProcedureMapper; import java.time.Year; -import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -86,10 +85,10 @@ public final class ChildMapper { return examinations.stream().map(ExaminationMapper::mapToDto).toList(); } - private static List<FluoridationConsentDto> mapFluoridationToDto( + public static List<FluoridationConsentDto> mapFluoridationToDto( List<FluoridationConsent> fluoridationConsent) { if (fluoridationConsent == null) { - return new ArrayList<>(); + return List.of(); } return fluoridationConsent.stream() .map(f -> new FluoridationConsentDto(f.getDateOfConsent(), f.isConsented(), f.hasAllergy())) diff --git a/backend/dental/src/main/java/de/eshg/dental/mapper/DentitionTypeMapper.java b/backend/dental/src/main/java/de/eshg/dental/mapper/DentitionTypeMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..dcfaa5faa723766f12b471ec424861f7fd85cd7c --- /dev/null +++ b/backend/dental/src/main/java/de/eshg/dental/mapper/DentitionTypeMapper.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.dental.mapper; + +import de.eshg.dental.api.DentitionTypeDto; +import de.eshg.dental.domain.model.DentitionType; + +public final class DentitionTypeMapper { + private DentitionTypeMapper() {} + + public static DentitionType mapToDomain(DentitionTypeDto dto) { + return switch (dto) { + case null -> null; + case PRIMARY -> DentitionType.PRIMARY; + case MIXED -> DentitionType.MIXED; + case SECONDARY -> DentitionType.SECONDARY; + }; + } + + public static DentitionTypeDto mapToDto(DentitionType dentitionType) { + return switch (dentitionType) { + case null -> null; + case PRIMARY -> DentitionTypeDto.PRIMARY; + case MIXED -> DentitionTypeDto.MIXED; + case SECONDARY -> DentitionTypeDto.SECONDARY; + }; + } +} diff --git a/backend/dental/src/main/java/de/eshg/dental/mapper/ExaminationMapper.java b/backend/dental/src/main/java/de/eshg/dental/mapper/ExaminationMapper.java index 375938c00b5182c427cc50d0a25391990cecaf84..f167beb0053f16ce3733ebf099d241e6439d2067 100644 --- a/backend/dental/src/main/java/de/eshg/dental/mapper/ExaminationMapper.java +++ b/backend/dental/src/main/java/de/eshg/dental/mapper/ExaminationMapper.java @@ -6,17 +6,7 @@ package de.eshg.dental.mapper; import de.cronn.commons.lang.StreamUtil; -import de.eshg.dental.api.AbsenceExaminationResultDto; -import de.eshg.dental.api.ExaminationDto; -import de.eshg.dental.api.ExaminationResultDto; -import de.eshg.dental.api.FluoridationExaminationResultDto; -import de.eshg.dental.api.MainResultDto; -import de.eshg.dental.api.OralHygieneStatusDto; -import de.eshg.dental.api.ReasonForAbsenceDto; -import de.eshg.dental.api.ScreeningExaminationResultDto; -import de.eshg.dental.api.SecondaryResultDto; -import de.eshg.dental.api.ToothDiagnosisDto; -import de.eshg.dental.api.ToothDto; +import de.eshg.dental.api.*; import de.eshg.dental.domain.model.AbsenceExaminationResult; import de.eshg.dental.domain.model.Examination; import de.eshg.dental.domain.model.ExaminationResult; @@ -62,6 +52,7 @@ public final class ExaminationMapper { new ScreeningExaminationResultDto( screeningExaminationResult.isFluorideVarnishApplied(), mapToDto(screeningExaminationResult.getOralHygieneStatus()), + DentitionTypeMapper.mapToDto(screeningExaminationResult.getDentitionType()), mapToDto(screeningExaminationResult.getToothDiagnoses())); case AbsenceExaminationResult absenceExaminationResult -> new AbsenceExaminationResultDto(mapToDto(absenceExaminationResult.getReasonForAbsence())); diff --git a/backend/dental/src/main/java/de/eshg/dental/mapper/ProphylaxisSessionMapper.java b/backend/dental/src/main/java/de/eshg/dental/mapper/ProphylaxisSessionMapper.java index cd940595d6f90285b7c26a3922d6676c6b63891a..5729ce1285d6c34f3b8f70b377935bd34b9426cd 100644 --- a/backend/dental/src/main/java/de/eshg/dental/mapper/ProphylaxisSessionMapper.java +++ b/backend/dental/src/main/java/de/eshg/dental/mapper/ProphylaxisSessionMapper.java @@ -19,9 +19,11 @@ import de.eshg.dental.api.ProphylaxisTypeDto; import de.eshg.dental.business.model.ProphylaxisSessionWithAugmentedData; import de.eshg.dental.business.model.ProphylaxisSessionWithAugmentedInstitution; import de.eshg.dental.domain.model.Examination; +import de.eshg.dental.domain.model.FluoridationConsent; import de.eshg.dental.domain.model.FluoridationVarnish; import de.eshg.dental.domain.model.ProphylaxisSession; import de.eshg.dental.domain.model.ProphylaxisType; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; @@ -85,6 +87,7 @@ public final class ProphylaxisSessionMapper { session.getGroupName(), mapToDto(session.getType()), session.isScreening(), + DentitionTypeMapper.mapToDto(session.getDentitionType()), mapToDto(session.getFluoridationVarnish()), getParticipants(prophylaxisSession), mapPersons(session.getDentistIds(), userMap), @@ -127,7 +130,10 @@ public final class ProphylaxisSessionMapper { examination.getChild().getGroupName().trim(), fileStateResponse.gender(), examination.getNote(), - examination.getChild().isFluoridationConsentCurrentlyGivenOptionally(), + ChildMapper.mapFluoridationToDto( + examination.getChild().getFluoridationConsents().stream() + .sorted(Comparator.comparing(FluoridationConsent::getModifiedAt).reversed()) + .toList()), ExaminationMapper.mapToDto(examination.getResult()), previousExaminations.stream() .map(Examination::getResult) diff --git a/backend/dental/src/main/java/de/eshg/dental/testhelper/ProphylaxisSessionsPopulator.java b/backend/dental/src/main/java/de/eshg/dental/testhelper/ProphylaxisSessionsPopulator.java index 32c187ce4da15dfbd3c988b002e0df6664f66530..285e90d12c89791de5ffc318689bee542627e158 100644 --- a/backend/dental/src/main/java/de/eshg/dental/testhelper/ProphylaxisSessionsPopulator.java +++ b/backend/dental/src/main/java/de/eshg/dental/testhelper/ProphylaxisSessionsPopulator.java @@ -16,6 +16,7 @@ import de.eshg.dental.ProphylaxisSessionController; import de.eshg.dental.api.AbsenceExaminationResultDto; import de.eshg.dental.api.CreateProphylaxisSessionRequest; import de.eshg.dental.api.CreateProphylaxisSessionResponse; +import de.eshg.dental.api.DentitionTypeDto; import de.eshg.dental.api.ExaminationResultDto; import de.eshg.dental.api.FluoridationExaminationResultDto; import de.eshg.dental.api.FluoridationVarnishDto; @@ -131,13 +132,15 @@ public class ProphylaxisSessionsPopulator .map(UserDto::userId) .toList(); + boolean isScreening = faker.random().nextBoolean(); CreateProphylaxisSessionRequest createProphylaxisSessionRequest = new CreateProphylaxisSessionRequest( date, institutionId, groupName, randomProphylaxisType(faker), - faker.random().nextBoolean(), + isScreening, + isScreening ? randomDentitionType(faker) : null, randomFluoridationVarnish(faker), dentistIds, zfaIds); @@ -160,6 +163,10 @@ public class ProphylaxisSessionsPopulator return randomElement(faker, ProphylaxisTypeDto.values()); } + private static DentitionTypeDto randomDentitionType(Faker faker) { + return randomElement(faker, DentitionTypeDto.values()); + } + private static FluoridationVarnishDto randomFluoridationVarnish(Faker faker) { return optional(faker, randomElement(faker, FluoridationVarnishDto.values())); } @@ -199,6 +206,7 @@ public class ProphylaxisSessionsPopulator optional( faker, hasFluoridationVarnish && isFluoridationConsentGiven && faker.bool().bool()), optional(faker, randomElement(faker, OralHygieneStatusDto.values())), + randomDentitionType(faker), randomToothDiagnoses(faker)); } else if (hasFluoridationVarnish) { return new FluoridationExaminationResultDto( diff --git a/backend/dental/src/main/resources/migrations/0033_add_prophylaxis_session_dentition_type.xml b/backend/dental/src/main/resources/migrations/0033_add_prophylaxis_session_dentition_type.xml new file mode 100644 index 0000000000000000000000000000000000000000..66d60de90f3cc9d245d87e8e5eab3a92139a0c78 --- /dev/null +++ b/backend/dental/src/main/resources/migrations/0033_add_prophylaxis_session_dentition_type.xml @@ -0,0 +1,23 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 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="1738661471240-1"> + <ext:createPostgresEnumType name="dentitiontype" + values="MIXED, PRIMARY, SECONDARY"/> + <addColumn tableName="prophylaxis_session"> + <column name="dentition_type" type="DENTITIONTYPE"/> + </addColumn> + <sql> + UPDATE prophylaxis_session + SET dentition_type='MIXED' + WHERE is_screening IS TRUE + </sql> + </changeSet> +</databaseChangeLog> diff --git a/backend/dental/src/main/resources/migrations/0034_differentiate_between_previous_person_and_facility_file_state.xml b/backend/dental/src/main/resources/migrations/0034_differentiate_between_previous_person_and_facility_file_state.xml new file mode 100644 index 0000000000000000000000000000000000000000..34abb256e08073f67d60ba852318586dba66a008 --- /dev/null +++ b/backend/dental/src/main/resources/migrations/0034_differentiate_between_previous_person_and_facility_file_state.xml @@ -0,0 +1,21 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 cronn GmbH + SPDX-License-Identifier: Apache-2.0 +--> + +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> + <changeSet author="GA-Lotse" id="1738231823333-1"> + <renameColumn tableName="system_progress_entry" + oldColumnName="previous_file_state_id" + newColumnName="previous_person_file_state_id"/> + <addColumn tableName="system_progress_entry"> + <column name="previous_facility_file_state_id" type="UUID"/> + </addColumn> + <addUniqueConstraint columnNames="previous_facility_file_state_id" + constraintName="system_progress_entry_previous_facility_file_state_id_key" + tableName="system_progress_entry"/> + </changeSet> +</databaseChangeLog> diff --git a/backend/dental/src/main/resources/migrations/0035_add_examination_result_dentition_type.xml b/backend/dental/src/main/resources/migrations/0035_add_examination_result_dentition_type.xml new file mode 100644 index 0000000000000000000000000000000000000000..5b1faea04b8f09859c1fd5eef218b62722a2582f --- /dev/null +++ b/backend/dental/src/main/resources/migrations/0035_add_examination_result_dentition_type.xml @@ -0,0 +1,17 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 cronn GmbH + SPDX-License-Identifier: Apache-2.0 +--> + +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> + <changeSet author="GA-Lotse" id="1738915752366-1"> + <addColumn tableName="screening_examination_result"> + <column name="dentition_type" type="DENTITIONTYPE" value="MIXED"> + <constraints nullable="false"/> + </column> + </addColumn> + </changeSet> +</databaseChangeLog> diff --git a/backend/dental/src/main/resources/migrations/changelog.xml b/backend/dental/src/main/resources/migrations/changelog.xml index f8a472d321b9e423bae683ad0d2dac4fd695db07..69d0d858b70871262a9da597c1077cc432fe2a31 100644 --- a/backend/dental/src/main/resources/migrations/changelog.xml +++ b/backend/dental/src/main/resources/migrations/changelog.xml @@ -40,5 +40,8 @@ <include file="migrations/0030_add_modified_at_fluoridation.xml"/> <include file="migrations/0031_fluoride_varnish_applied_optional.xml"/> <include file="migrations/0032_migrate_examination_result_to_use_sequences.xml"/> + <include file="migrations/0033_add_prophylaxis_session_dentition_type.xml"/> + <include file="migrations/0034_differentiate_between_previous_person_and_facility_file_state.xml"/> + <include file="migrations/0035_add_examination_result_dentition_type.xml"/> </databaseChangeLog> diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml index 699a8b13117d71447d66fa5b7884fa186635440b..296be336280d61df51bf7c386b6e942dfa3e1738 100644 --- a/backend/docker-compose.yaml +++ b/backend/docker-compose.yaml @@ -19,6 +19,7 @@ services: service: auth-base environment: - spring.profiles.active=local, employee-portal + - eshg.synapse.internal.url=http://synapse:8008 ports: - 8092:8080 @@ -370,7 +371,7 @@ services: file: docker-compose-common.yaml service: eshg-service-base environment: - - synapse.url=http://synapse:8008 + - eshg.synapse.internal.url=http://synapse:8008 - spring.datasource.url=jdbc:postgresql://chat-management-db/chat_management - de.eshg.base.service-url=http://base:8080 - DE_ESHG_CHAT_FEATURE_TOGGLE_ENABLED_NEW_FEATURES diff --git a/backend/file-commons/src/main/java/de/eshg/file/common/CsvValidator.java b/backend/file-commons/src/main/java/de/eshg/file/common/CsvValidator.java new file mode 100644 index 0000000000000000000000000000000000000000..9aff4a822f50e3daf8d69a1e5f5e5f65f63043bb --- /dev/null +++ b/backend/file-commons/src/main/java/de/eshg/file/common/CsvValidator.java @@ -0,0 +1,49 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.file.common; + +import de.eshg.rest.service.error.BadRequestException; +import java.util.List; +import java.util.Objects; + +public class CsvValidator { + + private CsvValidator() {} + + private static final List<ForbiddenSequence> forbiddenSequence = + List.of( + new ForbiddenSequence("\t", "CSV contains forbidden character: tabulator"), + new ForbiddenSequence("\r", "CSV contains forbidden character: carriage return"), + new ForbiddenSequence(",="), + new ForbiddenSequence(";="), + new ForbiddenSequence(",+"), + new ForbiddenSequence(";+"), + new ForbiddenSequence(",-"), + new ForbiddenSequence(";-"), + new ForbiddenSequence(",@"), + new ForbiddenSequence(";@")); + + public static void validate(byte[] fileContent) { + + for (ForbiddenSequence forbiddenSequence : forbiddenSequence) { + if (new String(fileContent).contains(forbiddenSequence.sequence)) { + throw new BadRequestException(forbiddenSequence.getEffectiveErrorMessage()); + } + } + } + + private record ForbiddenSequence(String sequence, String customErrorMessage) { + ForbiddenSequence(String sequence) { + this(sequence, null); + } + + private String getEffectiveErrorMessage() { + return Objects.requireNonNullElseGet( + customErrorMessage, + () -> "CSV contains forbidden character sequence: %s".formatted(sequence)); + } + } +} diff --git a/backend/inspection/openApi.json b/backend/inspection/openApi.json index 1d42add9facbd28723b88c031787f5401ee9ff01..72e3232b3dab9fe463ae7ef1e8ed8c8503a1904c 100644 --- a/backend/inspection/openApi.json +++ b/backend/inspection/openApi.json @@ -9954,7 +9954,11 @@ "type" : "integer", "format" : "int32" }, - "previousFileStateId" : { + "previousFacilityFileStateId" : { + "type" : "string", + "format" : "uuid" + }, + "previousPersonFileStateId" : { "type" : "string", "format" : "uuid" }, diff --git a/backend/inspection/src/main/java/de/eshg/inspection/facility/FacilityController.java b/backend/inspection/src/main/java/de/eshg/inspection/facility/FacilityController.java index dd5ac802257f4b7ae26163fd3b9c8f16e7f001cf..ba1b894864fe6d7971e1ac5fa2befcfc6148b3ee 100644 --- a/backend/inspection/src/main/java/de/eshg/inspection/facility/FacilityController.java +++ b/backend/inspection/src/main/java/de/eshg/inspection/facility/FacilityController.java @@ -83,7 +83,7 @@ public class FacilityController { @GetMapping(path = "/pending") @Operation(summary = "get overview of facilities with pending inspections") - @Transactional + @Transactional(readOnly = true) public InspPendingFacilitiesOverviewResponse getPendingFacilities( @InlineParameterObject @ParameterObject @Valid GetPendingFacilitiesFilterOptionsDto filters, @InlineParameterObject @ParameterObject @Valid diff --git a/backend/inspection/src/main/java/de/eshg/inspection/facility/websearch/WebSearchJob.java b/backend/inspection/src/main/java/de/eshg/inspection/facility/websearch/WebSearchJob.java index 59f82de4762d46a70c01bcaadd8121e7112ff07f..fad70e90f57539f91ac453884ccefa26ec038e01 100644 --- a/backend/inspection/src/main/java/de/eshg/inspection/facility/websearch/WebSearchJob.java +++ b/backend/inspection/src/main/java/de/eshg/inspection/facility/websearch/WebSearchJob.java @@ -33,7 +33,9 @@ public class WebSearchJob { } @Scheduled(cron = "${eshg.inspection.scheduling.job.websearch.cron}") - @SchedulerLock(name = "scheduledTaskName") + @SchedulerLock( + name = "scheduledTaskName", + lockAtMostFor = "${eshg.inspection.scheduling.job.websearch.lock-at-most-for:23h}") public void runJob() { LockAssert.assertLocked(); log.info("job {} starts...", getClass().getSimpleName()); diff --git a/backend/inspection/src/main/java/de/eshg/inspection/testhelper/InspectionTestHelperController.java b/backend/inspection/src/main/java/de/eshg/inspection/testhelper/InspectionTestHelperController.java index 476c2be8837c87d26350ef290a73fe3510db24d4..15c9ebf49a033bbc8e0a31f4f39a8fbbf8647834 100644 --- a/backend/inspection/src/main/java/de/eshg/inspection/testhelper/InspectionTestHelperController.java +++ b/backend/inspection/src/main/java/de/eshg/inspection/testhelper/InspectionTestHelperController.java @@ -11,6 +11,7 @@ import de.eshg.inspection.feature.InspectionFeature; import de.eshg.inspection.feature.InspectionFeatureToggle; import de.eshg.lib.auditlog.AuditLogTestHelperService; import de.eshg.testhelper.ConditionalOnTestHelperEnabled; +import de.eshg.testhelper.DefaultTestHelperService; import de.eshg.testhelper.TestHelperController; import de.eshg.testhelper.environment.EnvironmentConfig; import org.springframework.transaction.annotation.Transactional; @@ -29,12 +30,12 @@ public class InspectionTestHelperController extends TestHelperController private final ChecklistRepository checklistRepository; public InspectionTestHelperController( - InspectionTestHelperService inspectionTestHelperService, + DefaultTestHelperService testHelperService, AuditLogTestHelperService auditLogTestHelperService, InspectionFeatureToggle inspectionFeatureToggle, EnvironmentConfig environmentConfig, ChecklistRepository checklistRepository) { - super(inspectionTestHelperService, environmentConfig); + super(testHelperService, environmentConfig); this.auditLogTestHelperService = auditLogTestHelperService; this.inspectionFeatureToggle = inspectionFeatureToggle; this.checklistRepository = checklistRepository; diff --git a/backend/inspection/src/main/java/de/eshg/inspection/testhelper/InspectionTestHelperResetAction.java b/backend/inspection/src/main/java/de/eshg/inspection/testhelper/InspectionTestHelperResetAction.java new file mode 100644 index 0000000000000000000000000000000000000000..ab90c642e362e7fc1c9d0d4b88ea43802e1214c6 --- /dev/null +++ b/backend/inspection/src/main/java/de/eshg/inspection/testhelper/InspectionTestHelperResetAction.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 SCOOP Software GmbH, cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.inspection.testhelper; + +import de.eshg.inspection.objecttype.persistence.CreateObjectTypeTask; +import de.eshg.testhelper.ConditionalOnTestHelperEnabled; +import de.eshg.testhelper.TestHelperServiceResetAction; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@ConditionalOnTestHelperEnabled +@Component +@Order(50) +public class InspectionTestHelperResetAction implements TestHelperServiceResetAction { + private final CreateObjectTypeTask createObjectTypeTask; + private final ChecklistDefinitionTestDataProvider checklistDefinitionTestDataProvider; + + public InspectionTestHelperResetAction( + CreateObjectTypeTask createObjectTypeTask, + ChecklistDefinitionTestDataProvider checklistDefinitionTestDataProvider) { + this.createObjectTypeTask = createObjectTypeTask; + this.checklistDefinitionTestDataProvider = checklistDefinitionTestDataProvider; + } + + @Override + public void reset() { + createObjectTypeTask.createObjectTypes(); + checklistDefinitionTestDataProvider.clearTestCLDs(); + } +} diff --git a/backend/inspection/src/main/java/de/eshg/inspection/testhelper/InspectionTestHelperService.java b/backend/inspection/src/main/java/de/eshg/inspection/testhelper/InspectionTestHelperService.java deleted file mode 100644 index 318cc8527a5a4d9313543eff16502a7ea269d756..0000000000000000000000000000000000000000 --- a/backend/inspection/src/main/java/de/eshg/inspection/testhelper/InspectionTestHelperService.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2025 SCOOP Software GmbH, cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package de.eshg.inspection.testhelper; - -import de.eshg.inspection.objecttype.persistence.CreateObjectTypeTask; -import de.eshg.testhelper.*; -import de.eshg.testhelper.environment.EnvironmentConfig; -import de.eshg.testhelper.interception.TestRequestInterceptor; -import de.eshg.testhelper.population.BasePopulator; -import java.time.Clock; -import java.time.Instant; -import java.util.List; -import org.springframework.stereotype.Service; - -@ConditionalOnTestHelperEnabled -@Service -public class InspectionTestHelperService extends DefaultTestHelperService { - - private final CreateObjectTypeTask createObjectTypeTask; - private final ChecklistDefinitionTestDataProvider checklistDefinitionTestDataProvider; - - public InspectionTestHelperService( - DatabaseResetHelper databaseResetHelper, - TestRequestInterceptor testRequestInterceptor, - Clock clock, - List<BasePopulator<?>> populators, - List<ResettableProperties> resettableProperties, - CreateObjectTypeTask createObjectTypeTask, - EnvironmentConfig environmentConfig, - ChecklistDefinitionTestDataProvider checklistDefinitionTestDataProvider) { - super( - databaseResetHelper, - testRequestInterceptor, - clock, - populators, - resettableProperties, - environmentConfig); - this.createObjectTypeTask = createObjectTypeTask; - this.checklistDefinitionTestDataProvider = checklistDefinitionTestDataProvider; - } - - @Override - public Instant reset() throws Exception { - Instant newInstant = super.reset(); - createObjectTypeTask.createObjectTypes(); - checklistDefinitionTestDataProvider.clearTestCLDs(); - return newInstant; - } -} diff --git a/backend/inspection/src/main/resources/migrations/0066_differentiate_between_previous_person_and_facility_file_state.xml b/backend/inspection/src/main/resources/migrations/0066_differentiate_between_previous_person_and_facility_file_state.xml new file mode 100644 index 0000000000000000000000000000000000000000..d17304f9778b68569b6151cfb178d2a36b61ebb7 --- /dev/null +++ b/backend/inspection/src/main/resources/migrations/0066_differentiate_between_previous_person_and_facility_file_state.xml @@ -0,0 +1,21 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 SCOOP Software GmbH, cronn GmbH + SPDX-License-Identifier: AGPL-3.0-only +--> + +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> + <changeSet author="GA-Lotse" id="1738231823333-1"> + <renameColumn tableName="system_progress_entry" + oldColumnName="previous_file_state_id" + newColumnName="previous_person_file_state_id"/> + <addColumn tableName="system_progress_entry"> + <column name="previous_facility_file_state_id" type="UUID"/> + </addColumn> + <addUniqueConstraint columnNames="previous_facility_file_state_id" + constraintName="system_progress_entry_previous_facility_file_state_id_key" + tableName="system_progress_entry"/> + </changeSet> +</databaseChangeLog> diff --git a/backend/inspection/src/main/resources/migrations/changelog.xml b/backend/inspection/src/main/resources/migrations/changelog.xml index 0a5a6f7af7a979da56a5c54da7661cc5d253cfb3..d1ae374deeda98a0dc3760db4b1ce531e699d4e1 100644 --- a/backend/inspection/src/main/resources/migrations/changelog.xml +++ b/backend/inspection/src/main/resources/migrations/changelog.xml @@ -78,5 +78,6 @@ <include file="migrations/0062_add_cemetery_delete_at.xml"/> <include file="migrations/0063_add_previous_file_state_id_to_system_progress_entry.xml"/> <include file="migrations/0064_add_auditlog_entry.xml"/> + <include file="migrations/0066_differentiate_between_previous_person_and_facility_file_state.xml"/> </databaseChangeLog> 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 ec95381c8c17b250dab6ff2fe4bdabe999dd58ae..a17974624fc7dd2195d5c882e1c4c235e9c4a206 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 @@ -35,6 +35,8 @@ import de.eshg.lib.procedure.model.gdpr.GetGdprValidationTaskDetailsResponse; import de.eshg.lib.procedure.model.gdpr.GetGdprValidationTaskResponse; import de.eshg.lib.statistics.StatisticsApi; import de.eshg.lib.statistics.api.GetDataSourcesResponse; +import de.eshg.lib.statistics.api.GetDataTableHeaderRequest; +import de.eshg.lib.statistics.api.GetDataTableHeaderResponse; import de.eshg.lib.statistics.api.GetSpecificDataRequest; import de.eshg.lib.statistics.api.GetSpecificDataResponse; import de.eshg.rest.client.BearerAuthInterceptor; @@ -215,6 +217,12 @@ public class BusinessModuleClient return statisticsApiDelegate.getAvailableDataSources(); } + @Override + public GetDataTableHeaderResponse getDataTableHeader( + GetDataTableHeaderRequest getDataTableHeaderRequest) { + return statisticsApiDelegate.getDataTableHeader(getDataTableHeaderRequest); + } + @Override public GetSpecificDataResponse getSpecificData(GetSpecificDataRequest getSpecificDataRequest) { return statisticsApiDelegate.getSpecificData(getSpecificDataRequest); diff --git a/backend/lib-appointmentblock/openApi.json b/backend/lib-appointmentblock/openApi.json index 22ba0626c336863382ab5e8198ededc825c855d0..431e98befc9998f8ef2c46732c1faee2522bd611 100644 --- a/backend/lib-appointmentblock/openApi.json +++ b/backend/lib-appointmentblock/openApi.json @@ -474,7 +474,7 @@ }, "AppointmentType" : { "type" : "string", - "enum" : [ "CONSULTATION", "VACCINATION", "REGULAR_EXAMINATION", "CAN_CHILD", "ENTRY_LEVEL", "SPECIAL_NEEDS", "PROOF_SUBMISSION", "HIV_STI_CONSULTATION", "SEX_WORK", "RESULTS_REVIEW", "OFFICIAL_MEDICAL_SERVICE" ] + "enum" : [ "CONSULTATION", "VACCINATION", "REGULAR_EXAMINATION", "CAN_CHILD", "ENTRY_LEVEL", "SPECIAL_NEEDS", "PROOF_SUBMISSION", "HIV_STI_CONSULTATION", "SEX_WORK", "RESULTS_REVIEW", "OFFICIAL_MEDICAL_SERVICE_SHORT", "OFFICIAL_MEDICAL_SERVICE_LONG" ] }, "AppointmentTypeConfig" : { "required" : [ "appointmentTypeDto", "id", "standardDurationInMinutes" ], diff --git a/backend/lib-appointmentblock/src/main/java/de/eshg/lib/appointmentblock/api/AppointmentTypeDto.java b/backend/lib-appointmentblock/src/main/java/de/eshg/lib/appointmentblock/api/AppointmentTypeDto.java index 3502bc2fc29b8f644ea04ab92dca1b8ac31ccaea..f6d33d8e170608b359abcd6244b4732a15a75c72 100644 --- a/backend/lib-appointmentblock/src/main/java/de/eshg/lib/appointmentblock/api/AppointmentTypeDto.java +++ b/backend/lib-appointmentblock/src/main/java/de/eshg/lib/appointmentblock/api/AppointmentTypeDto.java @@ -19,5 +19,6 @@ public enum AppointmentTypeDto { HIV_STI_CONSULTATION, SEX_WORK, RESULTS_REVIEW, - OFFICIAL_MEDICAL_SERVICE + OFFICIAL_MEDICAL_SERVICE_SHORT, + OFFICIAL_MEDICAL_SERVICE_LONG, } diff --git a/backend/lib-appointmentblock/src/main/java/de/eshg/lib/appointmentblock/persistence/AppointmentType.java b/backend/lib-appointmentblock/src/main/java/de/eshg/lib/appointmentblock/persistence/AppointmentType.java index 6c62cc29aab14ad1cb2813222b60f5a22bf3c35d..8b7419527237e48dcfc77c612c78dcdb8177ac4d 100644 --- a/backend/lib-appointmentblock/src/main/java/de/eshg/lib/appointmentblock/persistence/AppointmentType.java +++ b/backend/lib-appointmentblock/src/main/java/de/eshg/lib/appointmentblock/persistence/AppointmentType.java @@ -16,5 +16,6 @@ public enum AppointmentType { HIV_STI_CONSULTATION, SEX_WORK, RESULTS_REVIEW, - OFFICIAL_MEDICAL_SERVICE + OFFICIAL_MEDICAL_SERVICE_SHORT, + OFFICIAL_MEDICAL_SERVICE_LONG, } diff --git a/backend/lib-appointmentblock/src/main/java/de/eshg/lib/appointmentblock/testhelper/AppointmentBlockTestHelperResetAction.java b/backend/lib-appointmentblock/src/main/java/de/eshg/lib/appointmentblock/testhelper/AppointmentBlockTestHelperResetAction.java new file mode 100644 index 0000000000000000000000000000000000000000..acb3d4222bcd0e2b7c8678d48c3cbe52f5cf6e12 --- /dev/null +++ b/backend/lib-appointmentblock/src/main/java/de/eshg/lib/appointmentblock/testhelper/AppointmentBlockTestHelperResetAction.java @@ -0,0 +1,30 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.lib.appointmentblock.testhelper; + +import de.eshg.lib.appointmentblock.persistence.CreateAppointmentTypeTask; +import de.eshg.testhelper.ConditionalOnTestHelperEnabled; +import de.eshg.testhelper.TestHelperServiceResetAction; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@ConditionalOnTestHelperEnabled +@Component +@Order(50) +public class AppointmentBlockTestHelperResetAction implements TestHelperServiceResetAction { + + private final CreateAppointmentTypeTask createAppointmentTypeTask; + + public AppointmentBlockTestHelperResetAction( + CreateAppointmentTypeTask createAppointmentTypeTask) { + this.createAppointmentTypeTask = createAppointmentTypeTask; + } + + @Override + public void reset() { + createAppointmentTypeTask.createAppointmentTypes(); + } +} diff --git a/backend/lib-appointmentblock/src/main/java/de/eshg/lib/appointmentblock/testhelper/AppointmentBlockTestHelperService.java b/backend/lib-appointmentblock/src/main/java/de/eshg/lib/appointmentblock/testhelper/AppointmentBlockTestHelperService.java deleted file mode 100644 index c06e2f090d5193510c100a85f615ae464e97083a..0000000000000000000000000000000000000000 --- a/backend/lib-appointmentblock/src/main/java/de/eshg/lib/appointmentblock/testhelper/AppointmentBlockTestHelperService.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: Apache-2.0 - */ - -package de.eshg.lib.appointmentblock.testhelper; - -import de.eshg.lib.appointmentblock.persistence.CreateAppointmentTypeTask; -import de.eshg.testhelper.*; -import de.eshg.testhelper.environment.EnvironmentConfig; -import de.eshg.testhelper.interception.TestRequestInterceptor; -import de.eshg.testhelper.population.BasePopulator; -import java.time.Clock; -import java.time.Instant; -import java.util.List; -import org.springframework.stereotype.Service; - -@ConditionalOnTestHelperEnabled -@Service -public class AppointmentBlockTestHelperService extends DefaultTestHelperService { - - private final CreateAppointmentTypeTask createAppointmentTypeTask; - - public AppointmentBlockTestHelperService( - DatabaseResetHelper databaseResetHelper, - TestRequestInterceptor testRequestInterceptor, - Clock clock, - List<BasePopulator<?>> populators, - List<ResettableProperties> resettableProperties, - CreateAppointmentTypeTask createAppointmentTypeTask, - EnvironmentConfig environmentConfig) { - super( - databaseResetHelper, - testRequestInterceptor, - clock, - populators, - resettableProperties, - environmentConfig); - this.createAppointmentTypeTask = createAppointmentTypeTask; - } - - @Override - public Instant reset() throws Exception { - Instant newInstant = super.reset(); - createAppointmentTypeTask.createAppointmentTypes(); - return newInstant; - } -} diff --git a/backend/lib-auditlog/build.gradle b/backend/lib-auditlog/build.gradle index 0578e7e9e22ae0ca42e00599fbadff9907b98aac..5c5ea09a39092ecb2ead728dfa261a9f3c838215 100644 --- a/backend/lib-auditlog/build.gradle +++ b/backend/lib-auditlog/build.gradle @@ -11,6 +11,7 @@ dependencies { implementation project(':test-helper-commons') implementation project(':lib-security-config-urls') implementation project(':business-module-persistence-commons') + implementation project(':lib-scheduling') implementation 'org.slf4j:slf4j-api' implementation 'org.springframework.boot:spring-boot-autoconfigure' diff --git a/backend/lib-auditlog/gradle.lockfile b/backend/lib-auditlog/gradle.lockfile index 67d2f3d65988e4861285dd6f19eca420fc534ab4..ee147b940c4b775195b04e494f59b97f730a0d3e 100644 --- a/backend/lib-auditlog/gradle.lockfile +++ b/backend/lib-auditlog/gradle.lockfile @@ -80,6 +80,9 @@ net.bytebuddy:byte-buddy-agent:1.15.11=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.15.11=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath net.datafaker:datafaker:2.4.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.13.0=testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-core:6.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.2.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-spring:6.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.5.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath net.minidev:json-smart:2.5.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath net.ttddyy:datasource-proxy:1.10=testFixturesRuntimeClasspath,testRuntimeClasspath diff --git a/backend/lib-auditlog/src/main/java/de/eshg/lib/auditlog/AuditLogArchiving.java b/backend/lib-auditlog/src/main/java/de/eshg/lib/auditlog/AuditLogArchiving.java index feca211bfbeff5bbca854a245eca7629a6225b8e..8abccfc4f98f1268d93909863781e42574707e3c 100644 --- a/backend/lib-auditlog/src/main/java/de/eshg/lib/auditlog/AuditLogArchiving.java +++ b/backend/lib-auditlog/src/main/java/de/eshg/lib/auditlog/AuditLogArchiving.java @@ -22,6 +22,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import net.javacrumbs.shedlock.core.LockAssert; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.web.client.HttpClientErrorException.BadRequest; @@ -52,8 +54,12 @@ public class AuditLogArchiving { } @Scheduled(cron = "${de.eshg.auditlog.archiving.schedule:@daily}") + @SchedulerLock( + name = "LibAuditLogAuditLogArchiving", + lockAtMostFor = "${de.eshg.auditlog.archiving.lock-at-most-for:23h}") @Transactional public void runArchivingJob() { + LockAssert.assertLocked(); moduleClientAuthenticator.doWithModuleClientAuthentication(this::archiveOldAuditlogFiles); } diff --git a/backend/lib-auditlog/src/main/java/de/eshg/lib/auditlog/spring/AuditLogScheduledArchivingConfiguration.java b/backend/lib-auditlog/src/main/java/de/eshg/lib/auditlog/spring/AuditLogScheduledArchivingConfiguration.java index 106830f522c5a1ed6cd9719079412e2a7df268f0..eb02ad649afba5291a753a74e3e4a5668e6fe64b 100644 --- a/backend/lib-auditlog/src/main/java/de/eshg/lib/auditlog/spring/AuditLogScheduledArchivingConfiguration.java +++ b/backend/lib-auditlog/src/main/java/de/eshg/lib/auditlog/spring/AuditLogScheduledArchivingConfiguration.java @@ -8,10 +8,8 @@ package de.eshg.lib.auditlog.spring; import de.eshg.testhelper.ConditionalOnTestHelperEnabled; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; -import org.springframework.scheduling.annotation.EnableScheduling; @Configuration -@EnableScheduling public class AuditLogScheduledArchivingConfiguration { @Configuration diff --git a/backend/lib-four-eyes-principle/gradle.lockfile b/backend/lib-four-eyes-principle/gradle.lockfile index 6b0be86d0196bf7f07d169023c4dbbdab0f37afa..17a4297d1d97cb1d44b8eb7f5d7e95d69b1148a5 100644 --- a/backend/lib-four-eyes-principle/gradle.lockfile +++ b/backend/lib-four-eyes-principle/gradle.lockfile @@ -81,6 +81,9 @@ net.bytebuddy:byte-buddy-agent:1.15.11=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.15.11=annotationProcessor,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.datafaker:datafaker:2.4.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.13.0=testCompileClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-core:6.2.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.2.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-spring:6.2.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.5.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.minidev:json-smart:2.5.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.ttddyy:datasource-proxy:1.10=testRuntimeClasspath diff --git a/backend/lib-keycloak/src/main/java/de/eshg/lib/keycloak/EmployeePermissionRole.java b/backend/lib-keycloak/src/main/java/de/eshg/lib/keycloak/EmployeePermissionRole.java index 60b325c9ea3e41e9f63de7bfa3f5e35024bbd2c6..b1fe27906e4bfef4f32519bec2ceb4740b0bd9ba 100644 --- a/backend/lib-keycloak/src/main/java/de/eshg/lib/keycloak/EmployeePermissionRole.java +++ b/backend/lib-keycloak/src/main/java/de/eshg/lib/keycloak/EmployeePermissionRole.java @@ -189,6 +189,10 @@ public enum EmployeePermissionRole implements PermissionRole { WRITE_PERMISSION_TEMPLATE.formatted("Statistiken"), "Kann Vorlagen erstellen und löschen, die Prozesskennzahlen aus verschiedenen Quellen zu statistischen Zwecken zusammentragen. Kann mithilfe einer solchen Vorlage einen Datensatz erstellen und statistisch auswerten lassen sowie die Resultate abrufen", Module.STATISTICS), + STATISTICS_STATISTICS_TECHNICAL_USER( + "Technischer User Statistiken", + "Technischer User des Statistik-Moduls, um die konkreten Statistikdaten von den Fachmodulen zusammenzutragen, nicht für echte User bestimmt", + Module.STATISTICS), INBOX_PROCEDURE_WRITE( WRITE_PERMISSION_TEMPLATE.formatted("Posteingangsvorgänge"), diff --git a/backend/lib-matrix-client/README_LICENSE.adoc b/backend/lib-matrix-client/README_LICENSE.adoc new file mode 100644 index 0000000000000000000000000000000000000000..87f2419aaf60835f287ea4b3d058bd1a2cd01097 --- /dev/null +++ b/backend/lib-matrix-client/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/lib-matrix-client/build.gradle b/backend/lib-matrix-client/build.gradle new file mode 100644 index 0000000000000000000000000000000000000000..968be7e197bc878a1d0dff9fca6d54f072d0cfd2 --- /dev/null +++ b/backend/lib-matrix-client/build.gradle @@ -0,0 +1,83 @@ +import org.apache.commons.io.FileUtils +import org.openapitools.generator.gradle.plugin.tasks.GenerateTask + +plugins { + id "eshg.java-lib" + id "de.undercouch.download" version "latest.release" + id "org.openapi.generator" version "latest.release" +} + +dependencies { + implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + implementation 'org.springframework:spring-web' + implementation 'jakarta.annotation:jakarta.annotation-api' + implementation 'org.openapitools:jackson-databind-nullable:latest.release' +} + +ext { + matrixSpecVersion = '1.12' + zipFile = layout.buildDirectory.file("matrix-spec-${matrixSpecVersion}.zip") + zipDir = layout.buildDirectory.dir("matrix-spec").get().asFile +} + +tasks.withType(JavaCompile).configureEach { + def removed = options.compilerArgs.remove("-Xlint:all,-processing") + assert removed: "Unexpected compiler Args: ${options.compilerArgs}" + options.compilerArgs.add("-Xlint:all,-serial,-deprecation,-this-escape") +} + +tasks.register('downloadMatrixSpec', Download) { + src "https://github.com/matrix-org/matrix-spec/archive/refs/tags/v${matrixSpecVersion}.zip" + dest zipFile + overwrite false +} + +tasks.register('verifyDownloadedMatrixSpec', Verify) { + dependsOn downloadMatrixSpec + src zipFile + algorithm 'SHA256' + checksum '4a8239325cd8c3b1f67103b05b59b279ab17017ae0d97817ef5c9e61d0e587b3' +} + +tasks.register('unzipDownloadedMatrixSpec', Copy) { + dependsOn verifyDownloadedMatrixSpec + inputs.file file(zipFile) + outputs.dir zipDir + + doFirst { + FileUtils.cleanDirectory(zipDir) + } + + from zipTree(zipFile) + into zipDir + include "matrix-spec-${matrixSpecVersion}/data/api/client-server/**" +} + +def registerGenerateMatrixClientTask(String type) { + String taskName = "generateMatrixClient-${type}" + String inputSpecPath = "${zipDir}/matrix-spec-${matrixSpecVersion}/data/api/client-server/${type}.yaml" + def outputDirPath = layout.buildDirectory.dir("generated/sources/matrix/${type}").get().asFile + def generateMatrixClientTask = tasks.register(taskName, GenerateTask) { + dependsOn unzipDownloadedMatrixSpec + generatorName = 'java' + library = 'restclient' + inputSpec = inputSpecPath + outputDir = outputDirPath.path + invokerPackage = "org.matrix.${type}" + modelPackage = "org.matrix.${type}.model" + apiPackage = "org.matrix.${type}.api" + + doFirst { + FileUtils.cleanDirectory(outputDirPath) + } + } + + sourceSets.main.java.srcDirs += "${outputDirPath}/src/main/java" + compileJava.dependsOn generateMatrixClientTask +} + +def clients = ["login", "refresh", "logout"] +clients.forEach { String client -> + registerGenerateMatrixClientTask(client) +} diff --git a/backend/lib-matrix-client/buildscript-gradle.lockfile b/backend/lib-matrix-client/buildscript-gradle.lockfile new file mode 100644 index 0000000000000000000000000000000000000000..5c5e43a3f918025bf86f27e6721e005423b2cd10 --- /dev/null +++ b/backend/lib-matrix-client/buildscript-gradle.lockfile @@ -0,0 +1,80 @@ +# 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. +com.fasterxml.jackson.core:jackson-annotations:2.17.1=classpath +com.fasterxml.jackson.core:jackson-core:2.17.1=classpath +com.fasterxml.jackson.core:jackson-databind:2.17.1=classpath +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.17.1=classpath +com.fasterxml.jackson.datatype:jackson-datatype-guava:2.17.1=classpath +com.fasterxml.jackson.datatype:jackson-datatype-joda:2.17.1=classpath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.1=classpath +com.fasterxml.jackson:jackson-bom:2.17.1=classpath +com.github.ben-manes.caffeine:caffeine:2.9.3=classpath +com.github.curious-odd-man:rgxgen:1.4=classpath +com.github.java-json-tools:btf:1.3=classpath +com.github.java-json-tools:jackson-coreutils-equivalence:1.0=classpath +com.github.java-json-tools:jackson-coreutils:2.0=classpath +com.github.java-json-tools:json-patch:1.13=classpath +com.github.java-json-tools:json-schema-core:1.2.14=classpath +com.github.java-json-tools:json-schema-validator:2.2.14=classpath +com.github.java-json-tools:msg-simple:1.2=classpath +com.github.java-json-tools:uri-template:0.10=classpath +com.github.jknack:handlebars-jackson2:4.3.1=classpath +com.github.jknack:handlebars:4.3.1=classpath +com.github.joschi.jackson:jackson-datatype-threetenbp:2.15.2=classpath +com.github.mifmif:generex:1.0.2=classpath +com.google.code.findbugs:jsr305:3.0.2=classpath +com.google.errorprone:error_prone_annotations:2.21.1=classpath +com.google.guava:failureaccess:1.0.1=classpath +com.google.guava:guava:32.1.3-jre=classpath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=classpath +com.googlecode.libphonenumber:libphonenumber:8.11.1=classpath +com.samskivert:jmustache:1.15=classpath +commons-cli:commons-cli:1.5.0=classpath +commons-codec:commons-codec:1.11=classpath +commons-io:commons-io:2.16.1=classpath +commons-logging:commons-logging:1.2=classpath +de.undercouch.download:de.undercouch.download.gradle.plugin:5.6.0=classpath +de.undercouch:gradle-download-task:5.6.0=classpath +dk.brics.automaton:automaton:1.11-8=classpath +io.swagger.core.v3:swagger-annotations:2.2.21=classpath +io.swagger.core.v3:swagger-core:2.2.21=classpath +io.swagger.core.v3:swagger-models:2.2.21=classpath +io.swagger.parser.v3:swagger-parser-core:2.1.22=classpath +io.swagger.parser.v3:swagger-parser-safe-url-resolver:2.1.22=classpath +io.swagger.parser.v3:swagger-parser-v2-converter:2.1.22=classpath +io.swagger.parser.v3:swagger-parser-v3:2.1.22=classpath +io.swagger.parser.v3:swagger-parser:2.1.22=classpath +io.swagger:swagger-annotations:1.6.14=classpath +io.swagger:swagger-compat-spec-parser:1.0.70=classpath +io.swagger:swagger-core:1.6.14=classpath +io.swagger:swagger-models:1.6.14=classpath +io.swagger:swagger-parser-safe-url-resolver:1.0.70=classpath +io.swagger:swagger-parser:1.0.70=classpath +jakarta.activation:jakarta.activation-api:1.2.2=classpath +jakarta.validation:jakarta.validation-api:2.0.2=classpath +jakarta.xml.bind:jakarta.xml.bind-api:2.3.3=classpath +javax.validation:validation-api:1.1.0.Final=classpath +joda-time:joda-time:2.10.14=classpath +net.java.dev.jna:jna:5.12.1=classpath +net.sf.jopt-simple:jopt-simple:5.0.4=classpath +org.apache.commons:commons-lang3:3.14.0=classpath +org.apache.commons:commons-text:1.10.0=classpath +org.apache.httpcomponents:httpclient:4.5.14=classpath +org.apache.httpcomponents:httpcore:4.4.16=classpath +org.apache.maven.resolver:maven-resolver-api:1.9.18=classpath +org.apache.maven.resolver:maven-resolver-util:1.9.18=classpath +org.checkerframework:checker-qual:3.37.0=classpath +org.commonmark:commonmark:0.21.0=classpath +org.mozilla:rhino:1.7.7.2=classpath +org.openapi.generator:org.openapi.generator.gradle.plugin:7.10.0=classpath +org.openapitools:openapi-generator-core:7.10.0=classpath +org.openapitools:openapi-generator-gradle-plugin:7.10.0=classpath +org.openapitools:openapi-generator:7.10.0=classpath +org.projectlombok:lombok:1.18.30=classpath +org.slf4j:slf4j-api:2.0.9=classpath +org.slf4j:slf4j-ext:1.7.36=classpath +org.slf4j:slf4j-simple:1.7.36=classpath +org.threeten:threetenbp:1.6.8=classpath +org.yaml:snakeyaml:2.2=classpath +empty= diff --git a/backend/lib-matrix-client/gradle.lockfile b/backend/lib-matrix-client/gradle.lockfile new file mode 100644 index 0000000000000000000000000000000000000000..cac2dce33e141b483ad663bd87382c3b68b4a32c --- /dev/null +++ b/backend/lib-matrix-client/gradle.lockfile @@ -0,0 +1,202 @@ +# 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.12=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.12=testCompileClasspath,testRuntimeClasspath +com.diffplug.durian:durian-swt.os:4.2.0=spotless-1757186549 +com.fasterxml.jackson.core:jackson-annotations:2.18.2=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.18.2=compileClasspath +com.fasterxml.jackson.core:jackson-core:2.18.2=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.18.2=compileClasspath +com.fasterxml.jackson.core:jackson-databind:2.18.2=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.18.2=compileClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2=compileClasspath +com.fasterxml.jackson:jackson-bom:2.18.2=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.18.2=compileClasspath +com.google.code.findbugs:jsr305:3.0.2=spotless865459188 +com.google.errorprone:error_prone_annotations:2.28.0=spotless865459188 +com.google.googlejavaformat:google-java-format:1.19.2=spotless865459188 +com.google.guava:failureaccess:1.0.2=spotless865459188 +com.google.guava:guava:33.3.1-jre=spotless865459188 +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=spotless865459188 +com.google.j2objc:j2objc-annotations:3.0.0=spotless865459188 +com.ibm.icu:icu4j:73.2=spotless-1757186549 +com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +commons-beanutils:commons-beanutils:1.9.4=spotless-1757186549 +commons-collections:commons-collections:3.2.2=spotless-1757186549 +commons-io:commons-io:2.13.0=spotless-1757186549 +commons-jxpath:commons-jxpath:1.3=spotless-1757186549 +dev.equo.ide:solstice:1.7.4=spotless-1757186549 +io.micrometer:micrometer-commons:1.14.2=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.14.2=compileClasspath +io.micrometer:micrometer-observation:1.14.2=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.14.2=compileClasspath +jakarta.activation:jakarta.activation-api:2.1.3=testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:1.3.5=spotless-1757186549 +jakarta.annotation:jakarta.annotation-api:2.1.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.inject:jakarta.inject-api:1.0.5=spotless-1757186549 +jakarta.servlet:jakarta.servlet-api:4.0.4=spotless-1757186549 +jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=testCompileClasspath,testRuntimeClasspath +javax.servlet.jsp:javax.servlet.jsp-api:2.3.3=spotless-1757186549 +net.bytebuddy:byte-buddy-agent:1.15.11=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.15.11=testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna-platform:5.13.0=spotless-1757186549 +net.minidev:accessors-smart:2.5.1=testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.5.1=testCompileClasspath,testRuntimeClasspath +org.apache.felix:org.apache.felix.scr:2.2.6=spotless-1757186549 +org.apache.logging.log4j:log4j-api:2.24.3=testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.24.3=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.assertj:assertj-core:3.26.3=testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.2.2=testCompileClasspath,testRuntimeClasspath +org.bouncycastle:bcpg-jdk18on:1.76=spotless-1757186549 +org.bouncycastle:bcprov-jdk18on:1.76=spotless-1757186549 +org.checkerframework:checker-qual:3.43.0=spotless865459188 +org.eclipse.emf:org.eclipse.emf.common:2.29.0=spotless-1757186549 +org.eclipse.emf:org.eclipse.emf.ecore.change:2.15.0=spotless-1757186549 +org.eclipse.emf:org.eclipse.emf.ecore.xmi:2.35.0=spotless-1757186549 +org.eclipse.emf:org.eclipse.emf.ecore:2.35.0=spotless-1757186549 +org.eclipse.jdt:org.eclipse.jdt.core.manipulation:1.19.100=spotless-1757186549 +org.eclipse.jdt:org.eclipse.jdt.launching:3.20.100=spotless-1757186549 +org.eclipse.jdt:org.eclipse.jdt.ui:3.30.0=spotless-1757186549 +org.eclipse.platform:org.eclipse.compare.core:3.8.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.compare:3.9.200=spotless-1757186549 +org.eclipse.platform:org.eclipse.core.commands:3.11.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.core.contenttype:3.9.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.core.databinding.observable:1.13.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.core.databinding.property:1.10.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.core.databinding:1.13.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.core.expressions:3.9.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.core.filebuffers:3.8.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.core.filesystem:1.10.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.core.jobs:3.15.0=spotless-1757186549 +org.eclipse.platform:org.eclipse.core.resources:3.19.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.core.runtime:3.29.0=spotless-1757186549 +org.eclipse.platform:org.eclipse.core.variables:3.6.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.debug.core:3.21.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.debug.ui:3.18.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.core.commands:1.1.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.core.contexts:1.12.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.core.di.annotations:1.8.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.core.di.extensions.supplier:0.17.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.core.di.extensions:0.18.0=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.core.di:1.9.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.core.services:2.4.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.emf.xpath:0.4.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.ui.bindings:0.14.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.ui.css.core:0.14.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.ui.css.swt.theme:0.14.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.ui.css.swt:0.15.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.ui.di:1.5.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.ui.dialogs:1.4.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.ui.ide:3.17.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.ui.model.workbench:2.4.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.ui.services:1.6.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.ui.widgets:1.4.0=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.ui.workbench.addons.swt:1.5.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.ui.workbench.renderers.swt:0.16.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.ui.workbench.swt:0.17.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.ui.workbench3:0.17.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.e4.ui.workbench:1.15.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.equinox.app:1.6.300=spotless-1757186549 +org.eclipse.platform:org.eclipse.equinox.bidi:1.4.300=spotless-1757186549 +org.eclipse.platform:org.eclipse.equinox.common:3.18.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.equinox.event:1.6.200=spotless-1757186549 +org.eclipse.platform:org.eclipse.equinox.p2.artifact.repository:1.5.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.equinox.p2.core:2.10.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.equinox.p2.engine:2.8.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.equinox.p2.jarprocessor:1.3.200=spotless-1757186549 +org.eclipse.platform:org.eclipse.equinox.p2.metadata.repository:1.5.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.equinox.p2.metadata:2.7.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.equinox.p2.repository:2.7.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.equinox.preferences:3.10.300=spotless-1757186549 +org.eclipse.platform:org.eclipse.equinox.registry:3.11.300=spotless-1757186549 +org.eclipse.platform:org.eclipse.equinox.security:1.4.0=spotless-1757186549 +org.eclipse.platform:org.eclipse.equinox.supplement:1.10.700=spotless-1757186549 +org.eclipse.platform:org.eclipse.help:3.10.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.jface.databinding:1.15.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.jface.text:3.24.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.jface:3.31.0=spotless-1757186549 +org.eclipse.platform:org.eclipse.ltk.core.refactoring:3.14.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.ltk.ui.refactoring:3.13.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.osgi:3.18.500=spotless-1757186549 +org.eclipse.platform:org.eclipse.search:3.15.200=spotless-1757186549 +org.eclipse.platform:org.eclipse.swt.cocoa.macosx.aarch64:3.124.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.swt.cocoa.macosx.x86_64:3.124.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.swt.gtk.linux.aarch64:3.124.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.swt.gtk.linux.ppc64le:3.124.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.swt.gtk.linux.x86_64:3.124.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.swt.win32.win32.x86_64:3.124.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.swt:3.124.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.team.core:3.10.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.team.ui:3.10.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.text:3.13.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.ui.console:3.13.0=spotless-1757186549 +org.eclipse.platform:org.eclipse.ui.editors:3.17.0=spotless-1757186549 +org.eclipse.platform:org.eclipse.ui.forms:3.13.0=spotless-1757186549 +org.eclipse.platform:org.eclipse.ui.ide:3.21.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.ui.navigator.resources:3.9.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.ui.navigator:3.12.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.ui.views.properties.tabbed:3.10.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.ui.views:3.12.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.ui.workbench.texteditor:3.17.100=spotless-1757186549 +org.eclipse.platform:org.eclipse.ui.workbench:3.130.0=spotless-1757186549 +org.eclipse.platform:org.eclipse.ui:3.204.0=spotless-1757186549 +org.eclipse.platform:org.eclipse.urischeme:1.3.100=spotless-1757186549 +org.glassfish:javax.el:3.0.0=spotless-1757186549 +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.11.4=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.11.4=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.11.4=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.11.4=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.11.4=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.11.4=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.11.4=testRuntimeClasspath +org.junit:junit-bom:5.11.3=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.14.2=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.14.2=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testRuntimeClasspath +org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.service.cm:1.6.1=spotless-1757186549 +org.osgi:org.osgi.service.component:1.5.1=spotless-1757186549 +org.osgi:org.osgi.service.event:1.4.1=spotless-1757186549 +org.osgi:org.osgi.service.metatype:1.4.1=spotless-1757186549 +org.osgi:org.osgi.service.prefs:1.1.2=spotless-1757186549 +org.osgi:org.osgi.util.function:1.2.0=spotless-1757186549 +org.osgi:org.osgi.util.promise:1.3.0=spotless-1757186549 +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.4.1=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:3.4.1=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:3.4.1=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:3.4.1=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:3.4.1=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:3.4.1=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:3.4.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:6.2.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:6.2.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:6.2.1=compileClasspath +org.springframework:spring-context:6.2.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:6.2.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:6.2.1=compileClasspath +org.springframework:spring-expression:6.2.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jcl:6.2.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jcl:6.2.1=compileClasspath +org.springframework:spring-test:6.2.1=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:6.2.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:6.2.1=compileClasspath +org.tukaani:xz:1.9=spotless-1757186549 +org.xmlunit:xmlunit-core:2.10.0=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.3=testCompileClasspath,testRuntimeClasspath +empty=annotationProcessor,developmentOnly,testAndDevelopmentOnly,testAnnotationProcessor,testFixturesCompileClasspath,testFixturesRuntimeClasspath diff --git a/backend/lib-notification/build.gradle b/backend/lib-notification/build.gradle index 813b22deb80909913c3cf606a99bb58e316e3f1b..89145748b58d6fc41d3d465d84083952bfc66e61 100644 --- a/backend/lib-notification/build.gradle +++ b/backend/lib-notification/build.gradle @@ -7,6 +7,7 @@ dependencies { api project(":lib-notification-api") implementation project(':business-module-persistence-commons') + implementation project(':lib-scheduling') implementation 'jakarta.persistence:jakarta.persistence-api' implementation 'org.springframework:spring-context' diff --git a/backend/lib-notification/gradle.lockfile b/backend/lib-notification/gradle.lockfile index 0bfd4105058ab9f0ec06d8453ff9ef88b394613c..763dc6390228c311dbd01fddfc7388124b6d5ddc 100644 --- a/backend/lib-notification/gradle.lockfile +++ b/backend/lib-notification/gradle.lockfile @@ -78,6 +78,9 @@ net.bytebuddy:byte-buddy-agent:1.15.11=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.15.11=annotationProcessor,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.datafaker:datafaker:2.4.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.13.0=testCompileClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-core:6.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.2.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-spring:6.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.5.1=testCompileClasspath,testRuntimeClasspath net.minidev:json-smart:2.5.1=testCompileClasspath,testRuntimeClasspath net.ttddyy:datasource-proxy:1.10=testRuntimeClasspath diff --git a/backend/lib-notification/src/main/java/de/eshg/lib/notification/NotificationHousekeeping.java b/backend/lib-notification/src/main/java/de/eshg/lib/notification/NotificationHousekeeping.java index f1ebb4c84b2103cd60acf9d5a0f474ab3183c0be..11803e4d3741a03274c28cc86bcc6e5469d549ba 100644 --- a/backend/lib-notification/src/main/java/de/eshg/lib/notification/NotificationHousekeeping.java +++ b/backend/lib-notification/src/main/java/de/eshg/lib/notification/NotificationHousekeeping.java @@ -14,6 +14,8 @@ import java.time.Period; import java.time.ZoneOffset; import java.util.Arrays; import java.util.List; +import net.javacrumbs.shedlock.core.LockAssert; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -39,7 +41,11 @@ public class NotificationHousekeeping { @Transactional @Scheduled(cron = "${de.eshg.notifications.housekeeping.schedule:@daily}") + @SchedulerLock( + name = "LibNotificationNotificationHousekeeping", + lockAtMostFor = "${de.eshg.notifications.housekeeping.lock-at-most-for:23h}") public void cleanupNotifications() { + LockAssert.assertLocked(); for (NotificationRepository<?> repository : notificationRepositories) { if (log.isInfoEnabled()) { log.info("Performing housekeeping for: {}", tryGetRepositoryName(repository)); diff --git a/backend/lib-notification/src/main/java/de/eshg/lib/notification/spring/config/NotificationLibrarySchedulingConfig.java b/backend/lib-notification/src/main/java/de/eshg/lib/notification/spring/config/NotificationLibrarySchedulingConfig.java index ed90e6e90fc21b39407a1e9e3bb4a828fba15adc..948a3e606ebd863fbe6adb046e30f46ae20fb19d 100644 --- a/backend/lib-notification/src/main/java/de/eshg/lib/notification/spring/config/NotificationLibrarySchedulingConfig.java +++ b/backend/lib-notification/src/main/java/de/eshg/lib/notification/spring/config/NotificationLibrarySchedulingConfig.java @@ -8,10 +8,8 @@ package de.eshg.lib.notification.spring.config; import de.eshg.testhelper.ConditionalOnTestHelperEnabled; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; -import org.springframework.scheduling.annotation.EnableScheduling; @Configuration -@EnableScheduling public class NotificationLibrarySchedulingConfig { @Configuration diff --git a/backend/lib-procedures-api/src/main/java/de/eshg/lib/procedure/model/SystemProgressEntryDto.java b/backend/lib-procedures-api/src/main/java/de/eshg/lib/procedure/model/SystemProgressEntryDto.java index 041d08f9efac1b847e203a5f2f44dd6cd5f8cd7f..4875877122da5be0b7b9c1a3fa75034580796a29 100644 --- a/backend/lib-procedures-api/src/main/java/de/eshg/lib/procedure/model/SystemProgressEntryDto.java +++ b/backend/lib-procedures-api/src/main/java/de/eshg/lib/procedure/model/SystemProgressEntryDto.java @@ -25,7 +25,8 @@ public final class SystemProgressEntryDto extends ProgressEntryDto private String keyDocumentType; private Integer keyDocumentVersion; private UUID triggeredBy; - private UUID previousFileStateId; + private UUID previousPersonFileStateId; + private UUID previousFacilityFileStateId; public String getSystemProgressEntryType() { return systemProgressEntryType; @@ -77,12 +78,12 @@ public final class SystemProgressEntryDto extends ProgressEntryDto this.keyDocumentType = keyDocumentType; } - public UUID getPreviousFileStateId() { - return previousFileStateId; + public UUID getPreviousPersonFileStateId() { + return previousPersonFileStateId; } - public void setPreviousFileStateId(UUID previousFileStateId) { - this.previousFileStateId = previousFileStateId; + public void setPreviousPersonFileStateId(UUID previousPersonFileStateId) { + this.previousPersonFileStateId = previousPersonFileStateId; } @Override @@ -91,4 +92,12 @@ public final class SystemProgressEntryDto extends ProgressEntryDto Optional.ofNullable(triggeredBy).ifPresent(userIds::add); return userIds; } + + public UUID getPreviousFacilityFileStateId() { + return previousFacilityFileStateId; + } + + public void setPreviousFacilityFileStateId(UUID previousFacilityFileStateId) { + this.previousFacilityFileStateId = previousFacilityFileStateId; + } } diff --git a/backend/lib-procedures/build.gradle b/backend/lib-procedures/build.gradle index a1262af5c34f87a63c99c01d520cb6052d05304a..17c2d4876a461105295ec93f5d13a2fa311cf512 100644 --- a/backend/lib-procedures/build.gradle +++ b/backend/lib-procedures/build.gradle @@ -18,6 +18,7 @@ dependencies { implementation project(':rest-oauth-client-commons') implementation project(':file-commons') implementation project(':lib-xdomea') + implementation project(':lib-scheduling') implementation 'jakarta.persistence:jakarta.persistence-api' implementation 'org.springframework:spring-context' diff --git a/backend/lib-procedures/gradle.lockfile b/backend/lib-procedures/gradle.lockfile index 7fc4bc1bee3d8ace632ecb9afd0b4d6bf3db18a6..3d9e1e60cc676e8ad61ff9b25c23e11e552f4a6d 100644 --- a/backend/lib-procedures/gradle.lockfile +++ b/backend/lib-procedures/gradle.lockfile @@ -92,6 +92,9 @@ net.bytebuddy:byte-buddy:1.15.11=annotationProcessor,productionRuntimeClasspath, net.datafaker:datafaker:2.4.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.15.0=testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath net.java.dev.stax-utils:stax-utils:20070216=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-core:6.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.2.0=productionRuntimeClasspath,runtimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-spring:6.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.5.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath net.minidev:json-smart:2.5.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath net.ttddyy:datasource-proxy:1.10=testFixturesRuntimeClasspath,testRuntimeClasspath diff --git a/backend/lib-procedures/openApi.json b/backend/lib-procedures/openApi.json index e7d6ad8f4a7fb9b4d2fc15d24ae734d7ded6cc4c..08f7c9f2c820ffaea7e548b52f44570d04a5468d 100644 --- a/backend/lib-procedures/openApi.json +++ b/backend/lib-procedures/openApi.json @@ -4543,7 +4543,11 @@ "type" : "integer", "format" : "int32" }, - "previousFileStateId" : { + "previousFacilityFileStateId" : { + "type" : "string", + "format" : "uuid" + }, + "previousPersonFileStateId" : { "type" : "string", "format" : "uuid" }, diff --git a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/domain/model/SystemProgressEntry.java b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/domain/model/SystemProgressEntry.java index 8dad0ba4996622d37bfe4845ef89fc58d74427c1..58e487676b01cea33436312c100879fa0b50c5fd 100644 --- a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/domain/model/SystemProgressEntry.java +++ b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/domain/model/SystemProgressEntry.java @@ -42,7 +42,11 @@ public non-sealed class SystemProgressEntry extends ProgressEntry @DataSensitivity(SensitivityLevel.PSEUDONYMIZED) @Column(unique = true) - private UUID previousFileStateId; + private UUID previousPersonFileStateId; + + @DataSensitivity(SensitivityLevel.PSEUDONYMIZED) + @Column(unique = true) + private UUID previousFacilityFileStateId; public String getSystemProgressEntryType() { return systemProgressEntryType; @@ -76,12 +80,20 @@ public non-sealed class SystemProgressEntry extends ProgressEntry this.changeDescription = changeDescription; } - public UUID getPreviousFileStateId() { - return previousFileStateId; + public UUID getPreviousPersonFileStateId() { + return previousPersonFileStateId; + } + + public void setPreviousPersonFileStateId(UUID previousFileStateId) { + this.previousPersonFileStateId = previousFileStateId; + } + + public UUID getPreviousFacilityFileStateId() { + return previousFacilityFileStateId; } - public void setPreviousFileStateId(UUID previousFileStateId) { - this.previousFileStateId = previousFileStateId; + public void setPreviousFacilityFileStateId(UUID previousFacilityFileStateId) { + this.previousFacilityFileStateId = previousFacilityFileStateId; } @Override 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 4af4870ef05a0b793657a6e454012c04ae9b4681..b6e653c80390b7c6f00924457ee5e81fb4993db0 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 @@ -20,7 +20,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.stream.Collectors; import java.util.stream.Stream; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @@ -243,24 +242,35 @@ public interface ProcedureRepository<ProcedureT extends Procedure<ProcedureT, ?, WHERE person.centralFileStateId IN :centralFileStateIds ORDER BY person.id ASC - UNION ALL + UNION SELECT facility.centralFileStateId AS centralFileStateId FROM #{#entityName} procedure JOIN procedure.relatedFacilities facility WHERE facility.centralFileStateId IN :centralFileStateIds ORDER BY facility.id ASC + + UNION + + SELECT systemProgressEntry.previousPersonFileStateId AS centralFileStateId + FROM #{#entityName} procedure + JOIN procedure.progressEntries progressEntry + JOIN treat(progressEntry as SystemProgressEntry) systemProgressEntry + WHERE systemProgressEntry.previousPersonFileStateId IN :centralFileStateIds + ORDER BY progressEntry.id ASC + + UNION + + SELECT systemProgressEntry.previousFacilityFileStateId AS centralFileStateId + FROM #{#entityName} procedure + JOIN procedure.progressEntries progressEntry + JOIN treat(progressEntry as SystemProgressEntry) systemProgressEntry + WHERE systemProgressEntry.previousFacilityFileStateId IN :centralFileStateIds + ORDER BY progressEntry.id ASC """) - List<UUID> findCentralFileStateIdsInUse( + List<UUID> findCentralFileStateIdsInUseNoDuplicates( @Param("centralFileStateIds") List<UUID> centralFileStateIds); - default List<UUID> findCentralFileStateIdsInUseNoDuplicates( - @Param("centralFileStateIds") List<UUID> centralFileStateIds) { - return findCentralFileStateIdsInUse(centralFileStateIds).stream() - .distinct() - .collect(Collectors.toList()); - } - @Query( """ SELECT procedure from #{#entityName} procedure diff --git a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/gdpr/GdprValidationTaskController.java b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/gdpr/GdprValidationTaskController.java index b5b425695b6a3b5b3d644921f87ee15c237197e6..ac962a1c8d5e0f9b8ad59e4da18305ab82379b81 100644 --- a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/gdpr/GdprValidationTaskController.java +++ b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/gdpr/GdprValidationTaskController.java @@ -38,6 +38,7 @@ import de.eshg.lib.procedure.model.gdpr.GetGdprNotificationBannerResponse; import de.eshg.lib.procedure.model.gdpr.GetGdprValidationTaskDetailsResponse; import de.eshg.lib.procedure.model.gdpr.GetGdprValidationTaskResponse; import de.eshg.lib.procedure.procedures.ProcedureDeletionService; +import de.eshg.persistence.IntentionalWritingTransaction; import de.eshg.rest.service.error.BadRequestException; import io.swagger.v3.oas.annotations.tags.Tag; import java.nio.charset.StandardCharsets; @@ -242,6 +243,7 @@ public class GdprValidationTaskController< @Override @Transactional + @IntentionalWritingTransaction(reason = "Audit logging") public GetGdprValidationTaskDetailsResponse getGdprValidationTaskDetails(UUID gdprId) { assertNewFeatureEnabled(BaseFeature.GDPR, baseFeatureTogglesApi.getFeatureToggles()); List<UUID> fileStateIds = service.getAndValidateFileStateIds(gdprId); diff --git a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/housekeeping/archiving/ArchivingJob.java b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/housekeeping/archiving/ArchivingJob.java index 7edfa30f3baa99296082459ae0ece159f2008f4c..c72bdc0589a8284b9b18fea6caa91786d125cdf4 100644 --- a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/housekeeping/archiving/ArchivingJob.java +++ b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/housekeeping/archiving/ArchivingJob.java @@ -25,6 +25,8 @@ import java.util.Collection; import java.util.List; import java.util.Set; import java.util.stream.Collectors; +import net.javacrumbs.shedlock.core.LockAssert; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -59,7 +61,11 @@ public class ArchivingJob<ProcedureT extends Procedure<ProcedureT, ?, ?, ?>> { @Transactional @Scheduled(cron = "${de.eshg.lib.procedure.housekeeping.archiving.schedule:@daily}") + @SchedulerLock( + name = "LibProceduresArchivingJob", + lockAtMostFor = "${de.eshg.lib.procedure.housekeeping.archiving.lock-at-most-for:23h}") public void run() { + LockAssert.assertLocked(); boolean withinGracePeriod = isWithinGracePeriod(); logger.info( "Started with grace period of {} months, is within grace period: {}", diff --git a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/housekeeping/cemetery/CemeteryHousekeeping.java b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/housekeeping/cemetery/CemeteryHousekeeping.java index 337146e2adbd0577ee28b1c2125016ccc2ce9267..f1936bf9a6fa4c5370fb66d87ce52249a1dbe5f1 100644 --- a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/housekeeping/cemetery/CemeteryHousekeeping.java +++ b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/housekeeping/cemetery/CemeteryHousekeeping.java @@ -9,6 +9,8 @@ import de.eshg.lib.procedure.domain.repository.CemeteryRepository; import jakarta.transaction.Transactional; import java.time.Clock; import java.time.Instant; +import net.javacrumbs.shedlock.core.LockAssert; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -26,8 +28,12 @@ public class CemeteryHousekeeping { } @Scheduled(cron = "${de.eshg.lib.procedure.housekeeping.cemetery.schedule:@daily}") + @SchedulerLock( + name = "LibProceduresCemeteryHousekeeping", + lockAtMostFor = "${de.eshg.lib.procedure.housekeeping.cemetery.lock-at-most-for:23h}") @Transactional void run() { + LockAssert.assertLocked(); logger.info("Attempting to delete all cemetery entries with deleteAfter in the past"); long numberOfDeletedEntries = repository.deleteByDeleteAtBefore(Instant.now(clock)); logger.info("Successfully deleted {} cemetery entries", numberOfDeletedEntries); diff --git a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/housekeeping/inbox/InboxProcedureCleanupJob.java b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/housekeeping/inbox/InboxProcedureCleanupJob.java index b99d17d035d7135a846141e5f878e508de456c86..0af612b1a1a578db5a25abb064d84b2104cbd1df 100644 --- a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/housekeeping/inbox/InboxProcedureCleanupJob.java +++ b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/housekeeping/inbox/InboxProcedureCleanupJob.java @@ -13,6 +13,8 @@ import java.time.Instant; import java.time.LocalDate; import java.util.Set; import java.util.stream.Collectors; +import net.javacrumbs.shedlock.core.LockAssert; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -37,7 +39,11 @@ public class InboxProcedureCleanupJob { } @Scheduled(cron = "${de.eshg.lib.procedure.housekeeping.inbox.schedule:@daily}") + @SchedulerLock( + name = "LibProceduresInboxProcedureCleanupJob", + lockAtMostFor = "${de.eshg.lib.procedure.housekeeping.inbox.lock-at-most-for:23h}") void run() { + LockAssert.assertLocked(); Set<Long> inboxProceduresForDeletion = getInboxProcedures(); logger.info("Attempting to delete {} inbox procedures", inboxProceduresForDeletion.size()); if (logger.isDebugEnabled()) { diff --git a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/mapping/ProgressEntryMapper.java b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/mapping/ProgressEntryMapper.java index 61067522ff56012dc6bb1489f1c960c129f56c7e..843982def4e73bd6f619d3e029ce87e8ecb3e917 100644 --- a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/mapping/ProgressEntryMapper.java +++ b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/mapping/ProgressEntryMapper.java @@ -68,7 +68,10 @@ public class ProgressEntryMapper { systemProgressEntryDto.setTriggerType(toInterfaceType(progressEntry.getTriggerType())); systemProgressEntryDto.setKeyDocumentType(progressEntry.getKeyDocumentType()); systemProgressEntryDto.setKeyDocumentVersion(progressEntry.getKeyDocumentVersion()); - systemProgressEntryDto.setPreviousFileStateId(progressEntry.getPreviousFileStateId()); + systemProgressEntryDto.setPreviousPersonFileStateId( + progressEntry.getPreviousPersonFileStateId()); + systemProgressEntryDto.setPreviousFacilityFileStateId( + progressEntry.getPreviousFacilityFileStateId()); fillGeneralProgressEntry(systemProgressEntryDto, progressEntry); return systemProgressEntryDto; } diff --git a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/notifications/ApprovalRequestMailJob.java b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/notifications/ApprovalRequestMailJob.java index 3e4f354c1452a17eb530a44be0939a5723d8edcd..33b224b471916e660e997fdbcea17322d4a7fcbd 100644 --- a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/notifications/ApprovalRequestMailJob.java +++ b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/notifications/ApprovalRequestMailJob.java @@ -6,6 +6,8 @@ package de.eshg.lib.procedure.notifications; import de.eshg.lib.rest.oauth.client.commons.ModuleClientAuthenticator; +import net.javacrumbs.shedlock.core.LockAssert; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -22,7 +24,11 @@ public class ApprovalRequestMailJob { } @Scheduled(cron = "${de.eshg.lib.procedure.mailreminder.schedule:0 * * * * *}") + @SchedulerLock( + name = "LibProceduresApprovalRequestMailJob", + lockAtMostFor = "${de.eshg.lib.procedure.mailreminder.lock-at-most-for:1h}") public void sendApprovalRequestMailRemindersIfNecessary() { + LockAssert.assertLocked(); moduleClientAuthenticator.doWithModuleClientAuthentication( approvalRequestMailService::sendApprovalRequestMailRemindersIfNecessary); } diff --git a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/procedures/ProcedureDeletionService.java b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/procedures/ProcedureDeletionService.java index 62c0745f74735d0e0efc97a819e4f0e000844573..c8b54024cd3e0296a2d243adfab1b6ca9acb465d 100644 --- a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/procedures/ProcedureDeletionService.java +++ b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/procedures/ProcedureDeletionService.java @@ -5,6 +5,7 @@ package de.eshg.lib.procedure.procedures; +import de.cronn.commons.lang.StreamUtil; import de.eshg.base.centralfile.FacilityApi; import de.eshg.base.centralfile.PersonApi; import de.eshg.base.centralfile.api.DeleteFileStatesRequest; @@ -12,13 +13,15 @@ import de.eshg.lib.procedure.cemetery.CemeteryService; import de.eshg.lib.procedure.domain.model.Procedure; import de.eshg.lib.procedure.domain.model.RelatedFacility; import de.eshg.lib.procedure.domain.model.RelatedPerson; +import de.eshg.lib.procedure.domain.model.SystemProgressEntry; import de.eshg.lib.procedure.domain.repository.ProcedureRepository; import de.eshg.rest.service.error.NotFoundException; import java.time.Period; -import java.util.List; +import java.util.Objects; +import java.util.Set; import java.util.UUID; -import java.util.function.Function; -import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.hibernate.Hibernate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -126,41 +129,79 @@ public class ProcedureDeletionService<ProcedureT extends Procedure<ProcedureT, ? } protected void markRelatedFileStatesForDeletion(ProcedureT procedure) { - if (!procedure.getRelatedPersons().isEmpty()) { + Set<UUID> personFileStatesToDelete = collectPersonFileStatesToDelete(procedure); + if (!personFileStatesToDelete.isEmpty()) { log.debug( - "Attempting to mark {} related persons for deletion. ", - procedure.getRelatedPersons().size()); + "Attempting to mark {} related person file states for deletion. ", + personFileStatesToDelete.size()); personApi.markPersonFileStateForDeletion( - deletionRequest(procedure.getRelatedPersons(), RelatedPerson::getCentralFileStateId)); + new DeleteFileStatesRequest(personFileStatesToDelete)); } - if (!procedure.getRelatedFacilities().isEmpty()) { + Set<UUID> facilityFileStatesToDelete = collectFacilityFileStatesToDelete(procedure); + if (!facilityFileStatesToDelete.isEmpty()) { log.debug( - "Attempting to mark {} related facilities for deletion", - procedure.getRelatedFacilities().size()); + "Attempting to mark {} related facility file states for deletion", + facilityFileStatesToDelete.size()); facilityApi.markFacilityFileStateForDeletion( - deletionRequest( - procedure.getRelatedFacilities(), RelatedFacility::getCentralFileStateId)); + new DeleteFileStatesRequest(facilityFileStatesToDelete)); } } protected void deleteRelatedFileStatesDuringArchiving(ProcedureT procedure) { - if (!procedure.getRelatedPersons().isEmpty()) { - log.debug("Attempting to delete {} related persons", procedure.getRelatedPersons().size()); + Set<UUID> personFileStatesToDelete = collectPersonFileStatesToDelete(procedure); + if (!personFileStatesToDelete.isEmpty()) { + log.debug( + "Attempting to delete {} related persons file states", personFileStatesToDelete.size()); personApi.deletePersonFileStateDuringArchive( - deletionRequest(procedure.getRelatedPersons(), RelatedPerson::getCentralFileStateId)); + new DeleteFileStatesRequest(personFileStatesToDelete)); } - if (!procedure.getRelatedFacilities().isEmpty()) { + Set<UUID> relatedFacilitiesToDelete = collectFacilityFileStatesToDelete(procedure); + if (!relatedFacilitiesToDelete.isEmpty()) { log.debug( - "Attempting to delete {} related facilities", procedure.getRelatedFacilities().size()); + "Attempting to delete {} related facilities file states", + relatedFacilitiesToDelete.size()); facilityApi.deleteFacilityFileStateDuringArchive( - deletionRequest( - procedure.getRelatedFacilities(), RelatedFacility::getCentralFileStateId)); + new DeleteFileStatesRequest(relatedFacilitiesToDelete)); } } - private <T> DeleteFileStatesRequest deletionRequest( - List<T> entities, Function<T, UUID> uuidExtractor) { - return new DeleteFileStatesRequest( - entities.stream().map(uuidExtractor).collect(Collectors.toSet())); + private Set<UUID> collectPersonFileStatesToDelete(ProcedureT procedure) { + return Stream.concat( + streamCurrentPersonFileStates(procedure), streamPreviousPersonFileFileStates(procedure)) + .collect(StreamUtil.toLinkedHashSet()); + } + + private Set<UUID> collectFacilityFileStatesToDelete(ProcedureT procedure) { + return Stream.concat( + streamCurrentFacilityFileStates(procedure), streamPreviousFacilityFileStates(procedure)) + .collect(StreamUtil.toLinkedHashSet()); + } + + private Stream<UUID> streamCurrentPersonFileStates(ProcedureT procedure) { + return procedure.getRelatedPersons().stream().map(RelatedPerson::getCentralFileStateId); + } + + private Stream<UUID> streamPreviousPersonFileFileStates(ProcedureT procedure) { + return streamSystemProgressEntries(procedure) + .map(SystemProgressEntry::getPreviousPersonFileStateId) + .filter(Objects::nonNull); + } + + private Stream<UUID> streamPreviousFacilityFileStates(ProcedureT procedure) { + return streamSystemProgressEntries(procedure) + .map(SystemProgressEntry::getPreviousFacilityFileStateId) + .filter(Objects::nonNull); + } + + private <ProcedureT extends Procedure<ProcedureT, ?, ?, ?>> + Stream<UUID> streamCurrentFacilityFileStates(ProcedureT procedure) { + return procedure.getRelatedFacilities().stream().map(RelatedFacility::getCentralFileStateId); + } + + private Stream<SystemProgressEntry> streamSystemProgressEntries(ProcedureT procedure) { + return procedure.getProgressEntries().stream() + .map(Hibernate::unproxy) + .filter(SystemProgressEntry.class::isInstance) + .map(SystemProgressEntry.class::cast); } } diff --git a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/spring/ProcedureLibraryAutoConfiguration.java b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/spring/ProcedureLibraryAutoConfiguration.java index 75a182af951096063e8b2650cb1bbf584dbfb18f..b9d4b37de869dcd1845b3fb685f70f748c788dbb 100644 --- a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/spring/ProcedureLibraryAutoConfiguration.java +++ b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/spring/ProcedureLibraryAutoConfiguration.java @@ -62,10 +62,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; -import org.springframework.scheduling.annotation.EnableScheduling; @AutoConfiguration -@EnableScheduling @ConditionalOnProperty( name = "de.eshg.lib.procedure.autoconfiguration-enabled", havingValue = "true", diff --git a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/spring/ProcedureLibrarySchedulingConfig.java b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/spring/ProcedureLibrarySchedulingConfig.java index f095c0d72b4c9fecfaa6f40b3b291e6826310010..3c0798344e7c15d8ff715a16db6cf4ca2ed6fa84 100644 --- a/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/spring/ProcedureLibrarySchedulingConfig.java +++ b/backend/lib-procedures/src/main/java/de/eshg/lib/procedure/spring/ProcedureLibrarySchedulingConfig.java @@ -8,10 +8,8 @@ package de.eshg.lib.procedure.spring; import de.eshg.testhelper.ConditionalOnTestHelperEnabled; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; -import org.springframework.scheduling.annotation.EnableScheduling; @Configuration -@EnableScheduling public class ProcedureLibrarySchedulingConfig { @Configuration @ConditionalOnTestHelperEnabled diff --git a/backend/lib-scheduling/build.gradle b/backend/lib-scheduling/build.gradle index 250bc633c806524fc6d7366c6d34fd00c745cc05..b03cfa25ac5edfa7aac7645e2fddbf74ce4c415b 100644 --- a/backend/lib-scheduling/build.gradle +++ b/backend/lib-scheduling/build.gradle @@ -6,6 +6,7 @@ dependencies { api 'net.javacrumbs.shedlock:shedlock-spring:latest.release' implementation project(':lib-commons') + implementation project(':test-helper-commons') implementation 'jakarta.persistence:jakarta.persistence-api' implementation 'org.springframework:spring-context' diff --git a/backend/lib-scheduling/gradle.lockfile b/backend/lib-scheduling/gradle.lockfile index ed7627ab54def74ed68b05fdc52edc14dd722c9e..22785c2b4432634ade4f1465b2ce97709c040d73 100644 --- a/backend/lib-scheduling/gradle.lockfile +++ b/backend/lib-scheduling/gradle.lockfile @@ -3,28 +3,54 @@ # This file is expected to be part of source control. ch.qos.logback:logback-classic:1.5.12=testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-core:1.5.12=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.18.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.18.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.18.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.18.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.curious-odd-man:rgxgen:2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.stephenc.jcip:jcip-annotations:1.0-1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.google.code.findbugs:jsr305:3.0.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.28.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.google.guava:failureaccess:1.0.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.google.guava:guava:33.3.1-jre=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.0.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.googlecode.libphonenumber:libphonenumber:8.13.50=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.nimbusds:nimbus-jose-jwt:9.37.3=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath de.cronn:commons-lang:1.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +de.cronn:reflection-util:2.17.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath io.micrometer:micrometer-commons:1.14.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.14.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-annotations-jakarta:2.2.28=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.swagger.core.v3:swagger-annotations:2.2.28=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.3=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:2.1.1=testCompileClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-core-jakarta:2.2.28=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-models-jakarta:2.2.28=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.3=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:2.1.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.persistence:jakarta.persistence-api:3.1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=testCompileClasspath,testRuntimeClasspath +jakarta.validation:jakarta.validation-api:3.0.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy-agent:1.15.11=testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.15.11=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.15.11=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.datafaker:datafaker:2.4.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.javacrumbs.shedlock:shedlock-core:6.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.javacrumbs.shedlock:shedlock-spring:6.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.5.1=testCompileClasspath,testRuntimeClasspath net.minidev:json-smart:2.5.1=testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.17.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-api:2.24.3=testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-to-slf4j:2.24.3=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:10.1.34=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.apache.tomcat:tomcat-annotations-api:10.1.34=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath org.assertj:assertj-core:3.26.3=testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.2.2=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.43.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.hamcrest:hamcrest:2.2=testCompileClasspath,testRuntimeClasspath org.jacoco:org.jacoco.agent:0.8.12=jacocoAgent,jacocoAnt org.jacoco:org.jacoco.ant:0.8.12=jacocoAnt @@ -40,7 +66,7 @@ org.junit.platform:junit-platform-launcher:1.11.4=testRuntimeClasspath org.junit:junit-bom:5.11.4=testCompileClasspath,testRuntimeClasspath org.mockito:mockito-core:5.14.2=testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.14.2=testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=testRuntimeClasspath +org.objenesis:objenesis:3.4=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath org.ow2.asm:asm-commons:9.7=jacocoAnt org.ow2.asm:asm-tree:9.7=jacocoAnt @@ -49,6 +75,7 @@ org.ow2.asm:asm:9.7=jacocoAnt 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=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-common:2.8.4=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:3.4.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:3.4.1=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:3.4.1=testCompileClasspath,testRuntimeClasspath @@ -56,6 +83,13 @@ org.springframework.boot:spring-boot-starter:3.4.1=testCompileClasspath,testRunt org.springframework.boot:spring-boot-test-autoconfigure:3.4.1=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-test:3.4.1=testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot:3.4.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-commons:3.4.1=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-core:6.4.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-crypto:6.4.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-core:6.4.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-jose:6.4.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-resource-server:6.4.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-web:6.4.2=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.springframework:spring-aop:6.2.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-beans:6.2.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-context:6.2.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -65,6 +99,7 @@ org.springframework:spring-jcl:6.2.1=compileClasspath,productionRuntimeClasspath org.springframework:spring-jdbc:6.2.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-test:6.2.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-tx:6.2.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:6.2.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlunit:xmlunit-core:2.10.0=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.3=testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath empty=annotationProcessor,developmentOnly,testAndDevelopmentOnly,testAnnotationProcessor,testFixturesCompileClasspath,testFixturesRuntimeClasspath diff --git a/backend/lib-scheduling/src/main/java/de/eshg/lib/scheduling/ShedlockResetAction.java b/backend/lib-scheduling/src/main/java/de/eshg/lib/scheduling/ShedlockResetAction.java new file mode 100644 index 0000000000000000000000000000000000000000..6a7473dd65b7917c61f439c8b49862c6f2d8ffaa --- /dev/null +++ b/backend/lib-scheduling/src/main/java/de/eshg/lib/scheduling/ShedlockResetAction.java @@ -0,0 +1,29 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.lib.scheduling; + +import de.eshg.testhelper.ConditionalOnTestHelperEnabled; +import de.eshg.testhelper.TestHelperServiceResetAction; +import net.javacrumbs.shedlock.support.StorageBasedLockProvider; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnTestHelperEnabled +@Order(40) +public class ShedlockResetAction implements TestHelperServiceResetAction { + + private final StorageBasedLockProvider storageBasedLockProvider; + + public ShedlockResetAction(StorageBasedLockProvider storageBasedLockProvider) { + this.storageBasedLockProvider = storageBasedLockProvider; + } + + @Override + public void reset() { + storageBasedLockProvider.clearCache(); + } +} diff --git a/backend/lib-scheduling/src/main/java/de/eshg/lib/scheduling/spring/SchedulingConfiguration.java b/backend/lib-scheduling/src/main/java/de/eshg/lib/scheduling/spring/SchedulingConfiguration.java index 2fe81a30d754c71c2027a202a446d518874a1d91..0abfb4b79bd6f21401b7bcb9442ed58a30c57599 100644 --- a/backend/lib-scheduling/src/main/java/de/eshg/lib/scheduling/spring/SchedulingConfiguration.java +++ b/backend/lib-scheduling/src/main/java/de/eshg/lib/scheduling/spring/SchedulingConfiguration.java @@ -5,10 +5,15 @@ package de.eshg.lib.scheduling.spring; +import de.eshg.testhelper.ConditionalOnTestHelperEnabled; +import java.util.Optional; +import java.util.function.Supplier; import javax.sql.DataSource; -import net.javacrumbs.shedlock.core.LockProvider; import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider; +import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider.Configuration.Builder; import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; +import net.javacrumbs.shedlock.support.StorageBasedLockProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.jdbc.core.JdbcTemplate; @@ -24,12 +29,25 @@ import org.springframework.scheduling.annotation.EnableScheduling; @EnableSchedulerLock(defaultLockAtMostFor = "1h") public class SchedulingConfiguration { + @FunctionalInterface + public interface LockedByValueSupplier extends Supplier<String> {} + + @Bean + @ConditionalOnTestHelperEnabled + public LockedByValueSupplier lockProviderLockedByValueSupplier() { + return () -> "[HOSTNAME]"; + } + @Bean - public LockProvider lockProvider(DataSource dataSource) { + public StorageBasedLockProvider lockProvider( + DataSource dataSource, + @Autowired(required = false) LockedByValueSupplier lockedByValueSupplier) { + Builder builder = JdbcTemplateLockProvider.Configuration.builder(); + + Optional.ofNullable(lockedByValueSupplier) + .ifPresent(supplier -> builder.withLockedByValue(supplier.get())); + return new JdbcTemplateLockProvider( - JdbcTemplateLockProvider.Configuration.builder() - .withJdbcTemplate(new JdbcTemplate(dataSource)) - .usingDbTime() - .build()); + builder.withJdbcTemplate(new JdbcTemplate(dataSource)).usingDbTime().build()); } } diff --git a/backend/lib-scheduling/src/main/java/de/eshg/lib/scheduling/spring/SchedulingLibraryAutoConfiguration.java b/backend/lib-scheduling/src/main/java/de/eshg/lib/scheduling/spring/SchedulingLibraryAutoConfiguration.java index a1d236f96f2ab0385c2ca2659957f03f23db1be7..3bdc7608b6cf2b91810d96cd3787d306eeeba8a6 100644 --- a/backend/lib-scheduling/src/main/java/de/eshg/lib/scheduling/spring/SchedulingLibraryAutoConfiguration.java +++ b/backend/lib-scheduling/src/main/java/de/eshg/lib/scheduling/spring/SchedulingLibraryAutoConfiguration.java @@ -5,7 +5,7 @@ package de.eshg.lib.scheduling.spring; -import de.eshg.lib.scheduling.Shedlock; +import de.eshg.lib.scheduling.ShedlockResetAction; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration; @@ -13,8 +13,5 @@ import org.springframework.context.annotation.Import; @AutoConfiguration @AutoConfigureAfter(JpaRepositoriesAutoConfiguration.class) -@Import({ - SchedulingConfiguration.class, - Shedlock.class, -}) +@Import({SchedulingConfiguration.class, ShedlockResetAction.class}) public class SchedulingLibraryAutoConfiguration {} diff --git a/backend/lib-scheduling/src/main/java/de/eshg/lib/scheduling/spring/SchedulingLibraryDomainModelAutoConfiguration.java b/backend/lib-scheduling/src/main/java/de/eshg/lib/scheduling/spring/SchedulingLibraryDomainModelAutoConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..e85552e6034ef078c09d281b2a2ce2f512f563a3 --- /dev/null +++ b/backend/lib-scheduling/src/main/java/de/eshg/lib/scheduling/spring/SchedulingLibraryDomainModelAutoConfiguration.java @@ -0,0 +1,15 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.lib.scheduling.spring; + +import de.eshg.lib.scheduling.Shedlock; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration; + +@AutoConfiguration(before = JpaRepositoriesAutoConfiguration.class) +@AutoConfigurationPackage(basePackageClasses = {Shedlock.class}) +public class SchedulingLibraryDomainModelAutoConfiguration {} diff --git a/backend/lib-scheduling/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/backend/lib-scheduling/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 8becc927dce7683775b643e21be83e7fb0ad8451..e6a40fedae8b440f066aabfb62686033447cbf72 100644 --- a/backend/lib-scheduling/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/backend/lib-scheduling/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1 +1,2 @@ de.eshg.lib.scheduling.spring.SchedulingLibraryAutoConfiguration +de.eshg.lib.scheduling.spring.SchedulingLibraryDomainModelAutoConfiguration diff --git a/backend/lib-security-config-urls/src/main/java/de/eshg/rest/service/security/config/BaseUrls.java b/backend/lib-security-config-urls/src/main/java/de/eshg/rest/service/security/config/BaseUrls.java index a0e009a9c2140e73f5cd7d2bed26936348eff0eb..036acbdaa30136dd9ceb59c982de45a00bf8cd30 100644 --- a/backend/lib-security-config-urls/src/main/java/de/eshg/rest/service/security/config/BaseUrls.java +++ b/backend/lib-security-config-urls/src/main/java/de/eshg/rest/service/security/config/BaseUrls.java @@ -163,6 +163,7 @@ public final class BaseUrls { public static final class StiProtection { public static final String PROCEDURE_CONTROLLER = "/sti-procedures"; + public static final String CITIZEN_CONTROLLER = "/citizen/auth"; public static final String CITIZEN_PUBLIC_CONTROLLER = "/citizen/public"; private StiProtection() {} @@ -210,6 +211,7 @@ public final class BaseUrls { public static final class ChatManagement { public static final String USER_SETTINGS_CONTROLLER = "/user-settings"; + public static final String USER_ACCOUNT_CONTROLLER = "/user-account"; public static final String FEATURE_TOGGLES_CONTROLLER = "/feature-toggles"; diff --git a/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/AbstractPublicSecurityConfiguration.java b/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/AbstractPublicSecurityConfiguration.java index 482f5bf1ae3eea42f90243f53f1e35375ab2bebc..92cec27ab22fd862c1474db3867cfea43cca68a8 100644 --- a/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/AbstractPublicSecurityConfiguration.java +++ b/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/AbstractPublicSecurityConfiguration.java @@ -209,13 +209,7 @@ public abstract class AbstractPublicSecurityConfiguration { } protected void grantAccessToStatistics(PermissionRole procedureAccessRole) { - requestMatchers(GET, BaseUrls.STATISTICS + "/**") - .hasAnyRole( - EmployeePermissionRole.STATISTICS_STATISTICS_READ, - EmployeePermissionRole.STATISTICS_STATISTICS_WRITE); requestMatchers(POST, BaseUrls.STATISTICS + "/procedure-ids/**").hasRole(procedureAccessRole); - requestMatchers(POST, BaseUrls.STATISTICS + "/specific-data/**") - .hasRole(EmployeePermissionRole.STATISTICS_STATISTICS_WRITE); } @CheckReturnValue diff --git a/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/ChatManagementPublicSecurityConfig.java b/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/ChatManagementPublicSecurityConfig.java index 8c57f167096a7b027221ca383382301bb8534a05..31bb1207b298bcfebeb35f61c5314ad06ff35014 100644 --- a/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/ChatManagementPublicSecurityConfig.java +++ b/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/ChatManagementPublicSecurityConfig.java @@ -16,6 +16,9 @@ public final class ChatManagementPublicSecurityConfig extends AbstractPublicSecu requestMatchers(BaseUrls.ChatManagement.USER_SETTINGS_CONTROLLER + "/**") .hasRole(EmployeePermissionRole.CHAT_MANAGEMENT_WRITE); + requestMatchers(BaseUrls.ChatManagement.USER_ACCOUNT_CONTROLLER + "/**") + .hasRole(EmployeePermissionRole.CHAT_MANAGEMENT_WRITE); + requestMatchers(BaseUrls.ChatManagement.FEATURE_TOGGLES_CONTROLLER + "/**") .hasRole(EmployeePermissionRole.STANDARD_EMPLOYEE); } diff --git a/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/StiProtectionPublicSecurityConfig.java b/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/StiProtectionPublicSecurityConfig.java index 6fdf4e181de42c1267641fa895157672dcf81f4c..43e5f51487d2f885305c1afa6162e3ab9b77590b 100644 --- a/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/StiProtectionPublicSecurityConfig.java +++ b/backend/lib-security-config/src/main/java/de/eshg/rest/service/security/config/StiProtectionPublicSecurityConfig.java @@ -7,6 +7,7 @@ package de.eshg.rest.service.security.config; import static org.springframework.http.HttpMethod.GET; +import de.eshg.lib.keycloak.CitizenPermissionRole; import de.eshg.lib.keycloak.EmployeePermissionRole; import de.eshg.lib.keycloak.ModuleLeaderRole; import org.springframework.stereotype.Component; @@ -29,5 +30,8 @@ public final class StiProtectionPublicSecurityConfig extends AbstractPublicSecur BaseUrls.StiProtection.PROCEDURE_CONTROLLER + "/**", BaseUrls.EVENT_METADATA_API + "/**") .hasAnyRole(EmployeePermissionRole.STI_PROTECTION_USER); + + requestMatchers(BaseUrls.StiProtection.CITIZEN_CONTROLLER + "/**") + .hasRole(CitizenPermissionRole.ACCESS_CODE_USER); } } diff --git a/backend/lib-service-directory-admin-api/src/main/java/de/eshg/libservicedirectoryadminapi/api/impex/ExportResponse.java b/backend/lib-service-directory-admin-api/src/main/java/de/eshg/libservicedirectoryadminapi/api/impex/ExportResponse.java index 5d1de9a1ca3a12be64ebfeeef7fc6e06ecadf472..0d68f11fb2f83e1b1aa7ec6de6d28d6f39fcd30e 100644 --- a/backend/lib-service-directory-admin-api/src/main/java/de/eshg/libservicedirectoryadminapi/api/impex/ExportResponse.java +++ b/backend/lib-service-directory-admin-api/src/main/java/de/eshg/libservicedirectoryadminapi/api/impex/ExportResponse.java @@ -7,9 +7,15 @@ package de.eshg.libservicedirectoryadminapi.api.impex; import de.eshg.libservicedirectoryadminapi.api.orgunit.OrgUnitDto; import de.eshg.libservicedirectoryadminapi.api.rule.RuleDto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import java.util.List; public record ExportResponse( - @NotNull @Valid List<OrgUnitDto> orgUnits, @NotNull @Valid List<RuleDto> rules) {} + @NotNull @Valid List<OrgUnitDto> orgUnits, @NotNull @Valid List<RuleDto> rules) { + @Schema(hidden = true) + public boolean isEmpty() { + return orgUnits.isEmpty() && rules.isEmpty(); + } +} diff --git a/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/StatisticsApi.java b/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/StatisticsApi.java index 9dcbe26e164a76316fc051c14a6e77064bbfdb3d..98edc2be9d3bc91cc4b760268ee0fa1ba08668ad 100644 --- a/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/StatisticsApi.java +++ b/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/StatisticsApi.java @@ -6,6 +6,8 @@ package de.eshg.lib.statistics; import de.eshg.lib.statistics.api.GetDataSourcesResponse; +import de.eshg.lib.statistics.api.GetDataTableHeaderRequest; +import de.eshg.lib.statistics.api.GetDataTableHeaderResponse; import de.eshg.lib.statistics.api.GetSpecificDataRequest; import de.eshg.lib.statistics.api.GetSpecificDataResponse; import de.eshg.rest.service.security.config.BaseUrls; @@ -28,6 +30,11 @@ public interface StatisticsApi { @Operation(summary = "Get available data sources") GetDataSourcesResponse getAvailableDataSources(); + @PostExchange("/data-table-header") + @Operation(summary = "Get data table header for the requested attributes") + GetDataTableHeaderResponse getDataTableHeader( + @Valid @RequestBody GetDataTableHeaderRequest getDataTableHeaderRequest); + @PostExchange("/specific-data") @Operation(summary = "Get specific data for the requested attributes") GetSpecificDataResponse getSpecificData( diff --git a/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/api/GetDataInformationRequest.java b/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/api/GetDataInformationRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..68da201a5c3acba8a2de015d450132cd8506a3d5 --- /dev/null +++ b/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/api/GetDataInformationRequest.java @@ -0,0 +1,23 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.lib.statistics.api; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public interface GetDataInformationRequest { + + Instant timeRangeStart(); + + Instant timeRangeEnd(); + + UUID dataSourceId(); + + boolean anonymizationRequired(); + + List<String> attributeCodes(); +} diff --git a/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/api/GetDataTableHeaderRequest.java b/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/api/GetDataTableHeaderRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..0b469163d5418f14d7b14968462b4aa76e715dbb --- /dev/null +++ b/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/api/GetDataTableHeaderRequest.java @@ -0,0 +1,19 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.lib.statistics.api; + +import jakarta.validation.constraints.NotNull; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record GetDataTableHeaderRequest( + @NotNull Instant timeRangeStart, + @NotNull Instant timeRangeEnd, + @NotNull UUID dataSourceId, + @NotNull boolean anonymizationRequired, + @NotNull List<String> attributeCodes) + implements GetDataInformationRequest {} diff --git a/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/api/GetDataTableHeaderResponse.java b/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/api/GetDataTableHeaderResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..3ae84b367dc7397e3e6726d8227ce622cc1c6625 --- /dev/null +++ b/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/api/GetDataTableHeaderResponse.java @@ -0,0 +1,18 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.lib.statistics.api; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; + +public record GetDataTableHeaderResponse( + @NotBlank String dataSourceName, + @NotNull Instant timeRangeStart, + @NotNull Instant timeRangeEnd, + @NotNull DataSourceSensitivity sensitivity, + @NotNull @Valid DataTableHeader dataTableHeader) {} diff --git a/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/api/GetSpecificDataRequest.java b/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/api/GetSpecificDataRequest.java index 66c0a947e7d18f0b34d39eada19e9fb6b6c83769..32912f279cdc377428501e93f206e2d0ecc62935 100644 --- a/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/api/GetSpecificDataRequest.java +++ b/backend/lib-statistics-api/src/main/java/de/eshg/lib/statistics/api/GetSpecificDataRequest.java @@ -20,7 +20,8 @@ public record GetSpecificDataRequest( @NotNull boolean anonymizationRequired, @NotNull List<String> attributeCodes, @Min(0) @Schema(defaultValue = "0") Integer page, - @Min(1) @Schema(defaultValue = "25") Integer pageSize) { + @Min(1) @Schema(defaultValue = "25") Integer pageSize) + implements GetDataInformationRequest { public GetSpecificDataRequest( Instant timeRangeStart, diff --git a/backend/lib-statistics/README.md b/backend/lib-statistics/README.md index 162965c4742702e0739e6fe3f6edde77a76e3130..7bb9dea1092e7f8dff09fae8cbf1299c6a879bc9 100644 --- a/backend/lib-statistics/README.md +++ b/backend/lib-statistics/README.md @@ -12,9 +12,6 @@ and A liquibase migration is needed for `ProcedureReferenceForStatistics` and the `StatisticsProcedureReferenceHousekeeping` with `shedlock`. -The role `STATISTICS_STATISTICS_WRITE` is used for most endpoints because reading -statistics information from a business module should only be done by users who can write statistics in the statistics module. - ## Anonymization If the business module supports anonymization (see below `canBeAnonymized` = true) there are currently these options to do that: * For all kinds of data sources: diff --git a/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/StatisticsController.java b/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/StatisticsController.java index fad2f811d2c12fdc0b2c358a9cc816d85c23e16e..3a3c249f50c70f6081ce0818f44460ffe4ba32c7 100644 --- a/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/StatisticsController.java +++ b/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/StatisticsController.java @@ -7,6 +7,8 @@ package de.eshg.lib.statistics; import de.eshg.lib.statistics.api.DataSourceInfo; import de.eshg.lib.statistics.api.GetDataSourcesResponse; +import de.eshg.lib.statistics.api.GetDataTableHeaderRequest; +import de.eshg.lib.statistics.api.GetDataTableHeaderResponse; import de.eshg.lib.statistics.api.GetSpecificDataRequest; import de.eshg.lib.statistics.api.GetSpecificDataResponse; import de.eshg.lib.statistics.datasource.DataSource; @@ -41,6 +43,13 @@ public class StatisticsController implements StatisticsApi { .toList()); } + @Override + @Transactional(readOnly = true) + public GetDataTableHeaderResponse getDataTableHeader( + GetDataTableHeaderRequest getDataTableHeaderRequest) { + return statisticsService.getDataTableHeader(getDataTableHeaderRequest); + } + @Override @Transactional public GetSpecificDataResponse getSpecificData(GetSpecificDataRequest getSpecificDataRequest) { diff --git a/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/StatisticsHousekeeping.java b/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/StatisticsHousekeeping.java index 2809094f3d7e614dbb6161f07e58bf852fe350dc..b1ae9e7e87997dcb2f9feef25fb1c2b8061751d8 100644 --- a/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/StatisticsHousekeeping.java +++ b/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/StatisticsHousekeeping.java @@ -38,7 +38,9 @@ public class StatisticsHousekeeping { } @Scheduled(cron = "${de.eshg.statistics.housekeeping.schedule:@daily}") - @SchedulerLock(name = "StatisticsHousekeeping") + @SchedulerLock( + name = "StatisticsHousekeeping", + lockAtMostFor = "${de.eshg.statistics.housekeeping.lock-at-most-for:23h}") @Transactional public void housekeeping() { LockAssert.assertLocked(); diff --git a/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/StatisticsService.java b/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/StatisticsService.java index e342c7702141e078ca079f4ea0f346b1bd0e5df0..9862345a11cbfdffd70fb4f09330aea46507fc2d 100644 --- a/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/StatisticsService.java +++ b/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/StatisticsService.java @@ -8,6 +8,9 @@ package de.eshg.lib.statistics; import de.cronn.commons.lang.StreamUtil; import de.eshg.lib.statistics.api.Attribute; import de.eshg.lib.statistics.api.DataTableHeader; +import de.eshg.lib.statistics.api.GetDataInformationRequest; +import de.eshg.lib.statistics.api.GetDataTableHeaderRequest; +import de.eshg.lib.statistics.api.GetDataTableHeaderResponse; import de.eshg.lib.statistics.api.GetSpecificDataRequest; import de.eshg.lib.statistics.api.GetSpecificDataResponse; import de.eshg.lib.statistics.api.ValueType; @@ -35,6 +38,7 @@ import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.function.Function; import org.springframework.stereotype.Service; import org.springframework.util.Assert; @@ -90,36 +94,63 @@ public class StatisticsService { }; } + public final GetDataTableHeaderResponse getDataTableHeader( + GetDataTableHeaderRequest getDataTableHeaderRequest) { + GetSpecificDataResponse specificDataResponse = + getSpecificDataResponse(getDataTableHeaderRequest, ignored -> DataRowPage.empty()); + return new GetDataTableHeaderResponse( + specificDataResponse.dataSourceName(), + specificDataResponse.timeRangeStart(), + specificDataResponse.timeRangeEnd(), + specificDataResponse.sensitivity(), + specificDataResponse.dataTableHeader()); + } + public final GetSpecificDataResponse getSpecificData( GetSpecificDataRequest getSpecificDataRequest) { - if (!getSpecificDataRequest.timeRangeStart().isBefore(getSpecificDataRequest.timeRangeEnd())) { + return getSpecificDataResponse( + getSpecificDataRequest, + dataForDataRowRetrieval -> + getDataRowPage( + dataForDataRowRetrieval.dataSource(), + getSpecificDataRequest, + dataForDataRowRetrieval.requestedAttributeInfos(), + dataForDataRowRetrieval.dataTableHeader())); + } + + private GetSpecificDataResponse getSpecificDataResponse( + GetDataInformationRequest getDataInformationRequest, + Function<DataForDataRowRetrieval, DataRowPage> dataRowPageFunction) { + if (!getDataInformationRequest + .timeRangeStart() + .isBefore(getDataInformationRequest.timeRangeEnd())) { throw new BadRequestException("Time range is invalid: start not before end"); } @SuppressWarnings("unchecked") DataSource<AttributeInfo> dataSource = - (DataSource<AttributeInfo>) getDataSource(getSpecificDataRequest.dataSourceId()); - if (getSpecificDataRequest.anonymizationRequired() && !dataSource.isCanBeAnonymized()) { + (DataSource<AttributeInfo>) getDataSource(getDataInformationRequest.dataSourceId()); + if (getDataInformationRequest.anonymizationRequired() && !dataSource.isCanBeAnonymized()) { throw new BadRequestException("Data cannot be anonymized"); } List<AttributeInfo> requestedAttributeInfos = - getRequestedAttributeInfos(getSpecificDataRequest.attributeCodes(), dataSource); + getRequestedAttributeInfos(getDataInformationRequest.attributeCodes(), dataSource); DataTableHeader dataTableHeader = getDataTableHeader(requestedAttributeInfos); DataRowPage dataRowPage = - getDataRowPage( - dataSource, getSpecificDataRequest, requestedAttributeInfos, dataTableHeader); + dataRowPageFunction.apply( + new DataForDataRowRetrieval(dataSource, requestedAttributeInfos, dataTableHeader)); return new GetSpecificDataResponse( dataSource.getName(), - getSpecificDataRequest.timeRangeStart(), - getSpecificDataRequest.timeRangeEnd(), + getDataInformationRequest.timeRangeStart(), + getDataInformationRequest.timeRangeEnd(), dataSource.getSensitivity(), - getSpecificDataRequest.anonymizationRequired(), + getDataInformationRequest.anonymizationRequired(), dataTableHeader, - getSpecificDataRequest.anonymizationRequired() + getDataInformationRequest.anonymizationRequired() ? dataSource.bulkAnonymizeDataRows(dataTableHeader, dataRowPage.dataRows()) : dataRowPage.dataRows(), dataRowPage.totalNumberOfElements()); @@ -172,4 +203,9 @@ public class StatisticsService { .map(this::mapToAttribute) .toList()); } + + private record DataForDataRowRetrieval( + DataSource<AttributeInfo> dataSource, + List<AttributeInfo> requestedAttributeInfos, + DataTableHeader dataTableHeader) {} } diff --git a/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/spring/config/StatisticsLibraryAutoConfiguration.java b/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/spring/config/StatisticsLibraryAutoConfiguration.java index 98df8ede9267d2f2dd66c7b01420f76424aeb276..8b1cb2530b9020115cdb0f1a64618a895f51db46 100644 --- a/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/spring/config/StatisticsLibraryAutoConfiguration.java +++ b/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/spring/config/StatisticsLibraryAutoConfiguration.java @@ -21,6 +21,7 @@ import org.springframework.context.annotation.Import; @Import({ StatisticsController.class, StatisticsService.class, + StatisticsLibraryInternalSecurityConfig.class, StatisticsProcedureReferenceController.class, StatisticsHousekeeping.class, StatisticsLibrarySchedulingConfig.class diff --git a/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/spring/config/StatisticsLibraryInternalSecurityConfig.java b/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/spring/config/StatisticsLibraryInternalSecurityConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..f15269d709fc85a47e1a588014f2f37cf4821787 --- /dev/null +++ b/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/spring/config/StatisticsLibraryInternalSecurityConfig.java @@ -0,0 +1,34 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.lib.statistics.spring.config; + +import static org.springframework.http.HttpMethod.GET; +import static org.springframework.http.HttpMethod.POST; + +import de.eshg.lib.keycloak.EmployeePermissionRole; +import de.eshg.rest.service.security.AuthorizationCustomizer; +import de.eshg.rest.service.security.config.BaseUrls; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class StatisticsLibraryInternalSecurityConfig { + + @Bean + public AuthorizationCustomizer statisticsAuthorizationCustomizer() { + return auth -> { + auth.requestMatchers(GET, BaseUrls.STATISTICS + "/**") + .hasAnyRole( + EmployeePermissionRole.STATISTICS_STATISTICS_READ.name(), + EmployeePermissionRole.STATISTICS_STATISTICS_WRITE.name(), + EmployeePermissionRole.STATISTICS_STATISTICS_TECHNICAL_USER.name()); + auth.requestMatchers(POST, BaseUrls.STATISTICS + "/data-table-header/**") + .hasRole(EmployeePermissionRole.STATISTICS_STATISTICS_WRITE.name()); + auth.requestMatchers(POST, BaseUrls.STATISTICS + "/specific-data/**") + .hasRole(EmployeePermissionRole.STATISTICS_STATISTICS_TECHNICAL_USER.name()); + }; + } +} diff --git a/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/spring/config/StatisticsLibrarySchedulingConfig.java b/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/spring/config/StatisticsLibrarySchedulingConfig.java index 36b9590a10471e6a79ca680defe1298d88fedd3e..0252ded874ddc6cfc13d1c08296ff1b8e73b89f8 100644 --- a/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/spring/config/StatisticsLibrarySchedulingConfig.java +++ b/backend/lib-statistics/src/main/java/de/eshg/lib/statistics/spring/config/StatisticsLibrarySchedulingConfig.java @@ -8,10 +8,8 @@ package de.eshg.lib.statistics.spring.config; import de.eshg.testhelper.ConditionalOnTestHelperEnabled; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; -import org.springframework.scheduling.annotation.EnableScheduling; @Configuration -@EnableScheduling public class StatisticsLibrarySchedulingConfig { @Configuration diff --git a/backend/measles-protection/gradle.lockfile b/backend/measles-protection/gradle.lockfile index 89edbacc475035ce593d8559971d3d782445a0b0..e190fef8e7711c98c268a6ce69129551d5679d09 100644 --- a/backend/measles-protection/gradle.lockfile +++ b/backend/measles-protection/gradle.lockfile @@ -93,6 +93,9 @@ net.bytebuddy:byte-buddy:1.15.11=annotationProcessor,productionRuntimeClasspath, net.datafaker:datafaker:2.4.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.13.0=testCompileClasspath,testRuntimeClasspath net.java.dev.stax-utils:stax-utils:20070216=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-core:6.2.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.2.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-spring:6.2.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath net.logstash.logback:logstash-logback-encoder:8.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.5.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.minidev:json-smart:2.5.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/backend/measles-protection/openApi.json b/backend/measles-protection/openApi.json index fe5c93144a61e7aee235faf042af836d7a7341f7..e30bdb71b6cd3cfca8016eb63e3fa2656af82ba5 100644 --- a/backend/measles-protection/openApi.json +++ b/backend/measles-protection/openApi.json @@ -4141,7 +4141,7 @@ }, "AppointmentType" : { "type" : "string", - "enum" : [ "CONSULTATION", "VACCINATION", "REGULAR_EXAMINATION", "CAN_CHILD", "ENTRY_LEVEL", "SPECIAL_NEEDS", "PROOF_SUBMISSION", "HIV_STI_CONSULTATION", "SEX_WORK", "RESULTS_REVIEW", "OFFICIAL_MEDICAL_SERVICE" ] + "enum" : [ "CONSULTATION", "VACCINATION", "REGULAR_EXAMINATION", "CAN_CHILD", "ENTRY_LEVEL", "SPECIAL_NEEDS", "PROOF_SUBMISSION", "HIV_STI_CONSULTATION", "SEX_WORK", "RESULTS_REVIEW", "OFFICIAL_MEDICAL_SERVICE_SHORT", "OFFICIAL_MEDICAL_SERVICE_LONG" ] }, "AppointmentTypeConfig" : { "required" : [ "appointmentTypeDto", "id", "standardDurationInMinutes" ], @@ -7211,7 +7211,11 @@ "type" : "integer", "format" : "int32" }, - "previousFileStateId" : { + "previousFacilityFileStateId" : { + "type" : "string", + "format" : "uuid" + }, + "previousPersonFileStateId" : { "type" : "string", "format" : "uuid" }, diff --git a/backend/measles-protection/src/main/java/de/eshg/measlesprotection/MeaslesProtectionService.java b/backend/measles-protection/src/main/java/de/eshg/measlesprotection/MeaslesProtectionService.java index afba45104100230ef8e824bc204c29e7c4498506..ccb1f389d368fd3cdca7fb6568199e229397e8fe 100644 --- a/backend/measles-protection/src/main/java/de/eshg/measlesprotection/MeaslesProtectionService.java +++ b/backend/measles-protection/src/main/java/de/eshg/measlesprotection/MeaslesProtectionService.java @@ -13,6 +13,7 @@ import de.eshg.base.centralfile.api.facility.GetFacilityFileStateResponse; import de.eshg.domain.model.BaseEntity_; import de.eshg.lib.appointmentblock.AppointmentMapper; import de.eshg.lib.procedure.domain.factory.SystemProgressEntryFactory; +import de.eshg.lib.procedure.domain.model.ProcedureStatus; import de.eshg.lib.procedure.domain.model.Procedure_; import de.eshg.lib.procedure.domain.model.RelatedPerson; import de.eshg.lib.procedure.domain.model.RelatedPerson_; @@ -52,6 +53,7 @@ import de.eshg.measlesprotection.persistence.db.ReportData; import de.eshg.measlesprotection.persistence.db.RoleStatus; import de.eshg.measlesprotection.persistence.support.MeaslesProtectionProcedureSpecification; import de.eshg.measlesprotection.persistence.support.ResultPage; +import de.eshg.rest.service.error.BadRequestException; import java.time.Clock; import java.time.Instant; import java.time.LocalDate; @@ -367,7 +369,11 @@ public class MeaslesProtectionService { @Transactional public void deleteProcedure(UUID id) { - procedureDeletionService.deleteAndWriteToCemetery( - procedureFinder.findProcedureByExternalId(id)); + MeaslesProtectionProcedure procedure = procedureFinder.findProcedureByExternalId(id); + if (procedure.getProcedureStatus().equals(ProcedureStatus.DRAFT)) { + procedureDeletionService.deleteAndWriteToCemetery(procedure); + } else { + throw new BadRequestException("Non-draft procedures cannot be deleted!"); + } } } diff --git a/backend/measles-protection/src/main/java/de/eshg/measlesprotection/OrganisationPortalController.java b/backend/measles-protection/src/main/java/de/eshg/measlesprotection/OrganisationPortalController.java index 18307c2f1aa6b5d904bf53c9ccb171e33b5c1668..87cfb2572e57607b835feb2eab5fa514b787577b 100644 --- a/backend/measles-protection/src/main/java/de/eshg/measlesprotection/OrganisationPortalController.java +++ b/backend/measles-protection/src/main/java/de/eshg/measlesprotection/OrganisationPortalController.java @@ -5,6 +5,9 @@ package de.eshg.measlesprotection; +import static de.eshg.rest.service.PrivacyDocumentHelper.privacyNoticeAttachmentResponse; +import static de.eshg.rest.service.PrivacyDocumentHelper.privacyPolicyAttachmentResponse; + import de.eshg.measlesprotection.api.citizenportal.ReportCaseRequest; import de.eshg.rest.service.security.config.BaseUrls; import io.swagger.v3.oas.annotations.Operation; @@ -12,8 +15,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; -import org.springframework.http.ContentDisposition; -import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -51,29 +52,12 @@ public class OrganisationPortalController { @GetMapping(path = "/documents/privacy-notice") @Operation(summary = "Get the privacy-notice document.") public ResponseEntity<Resource> getPrivacyNotice() { - return getPrivacyDocument(privacyNotice); + return privacyNoticeAttachmentResponse(privacyNotice); } @GetMapping(path = "/documents/privacy-policy") @Operation(summary = "Get the privacy-policy document.") public ResponseEntity<Resource> getPrivacyPolicy() { - return getPrivacyDocument(privacyPolicy); - } - - private static ResponseEntity<Resource> getPrivacyDocument(Resource privacyDocument) { - return ResponseEntity.ok() - .header( - HttpHeaders.CONTENT_DISPOSITION, - fileAttachment(privacyDocument.getFilename()).toString()) - .header(HttpHeaders.CONTENT_TYPE, "application/pdf") - .body(privacyDocument); - } - - private static ContentDisposition fileAttachment(String filename) { - return file(filename, ContentDisposition.attachment()); - } - - private static ContentDisposition file(String filename, ContentDisposition.Builder builder) { - return builder.name("file").filename(filename).build(); + return privacyPolicyAttachmentResponse(privacyPolicy); } } diff --git a/backend/measles-protection/src/main/java/de/eshg/measlesprotection/testhelper/MeaslesProtectionTestHelperService.java b/backend/measles-protection/src/main/java/de/eshg/measlesprotection/testhelper/MeaslesProtectionTestHelperService.java deleted file mode 100644 index a2dcd3cf058fc4b5500c28f7852da9655457e978..0000000000000000000000000000000000000000 --- a/backend/measles-protection/src/main/java/de/eshg/measlesprotection/testhelper/MeaslesProtectionTestHelperService.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package de.eshg.measlesprotection.testhelper; - -import de.eshg.lib.appointmentblock.persistence.CreateAppointmentTypeTask; -import de.eshg.testhelper.*; -import de.eshg.testhelper.environment.EnvironmentConfig; -import de.eshg.testhelper.interception.TestRequestInterceptor; -import de.eshg.testhelper.population.BasePopulator; -import java.time.Clock; -import java.time.Instant; -import java.util.List; -import org.springframework.stereotype.Service; - -@Service -@ConditionalOnTestHelperEnabled -public class MeaslesProtectionTestHelperService extends DefaultTestHelperService { - - private final CreateAppointmentTypeTask createAppointmentTypeTask; - - protected MeaslesProtectionTestHelperService( - DatabaseResetHelper databaseResetHelper, - TestRequestInterceptor testRequestInterceptor, - Clock clock, - List<BasePopulator<?>> populators, - List<ResettableProperties> resettableProperties, - CreateAppointmentTypeTask createAppointmentTypeTask, - EnvironmentConfig environmentConfig) { - super( - databaseResetHelper, - testRequestInterceptor, - clock, - populators, - resettableProperties, - environmentConfig); - this.createAppointmentTypeTask = createAppointmentTypeTask; - } - - @Override - public Instant reset() throws Exception { - Instant instant = super.reset(); - createAppointmentTypeTask.createAppointmentTypes(); - return instant; - } -} diff --git a/backend/measles-protection/src/main/java/de/eshg/measlesprotection/testhelper/MeaslesTestHelperResetAction.java b/backend/measles-protection/src/main/java/de/eshg/measlesprotection/testhelper/MeaslesTestHelperResetAction.java new file mode 100644 index 0000000000000000000000000000000000000000..20b5e1bb96a4ad17b1d732712aeb6b1c1bb939dd --- /dev/null +++ b/backend/measles-protection/src/main/java/de/eshg/measlesprotection/testhelper/MeaslesTestHelperResetAction.java @@ -0,0 +1,29 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.measlesprotection.testhelper; + +import de.eshg.lib.appointmentblock.persistence.CreateAppointmentTypeTask; +import de.eshg.testhelper.ConditionalOnTestHelperEnabled; +import de.eshg.testhelper.TestHelperServiceResetAction; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@ConditionalOnTestHelperEnabled +@Component +@Order(50) +public class MeaslesTestHelperResetAction implements TestHelperServiceResetAction { + + private final CreateAppointmentTypeTask createAppointmentTypeTask; + + public MeaslesTestHelperResetAction(CreateAppointmentTypeTask createAppointmentTypeTask) { + this.createAppointmentTypeTask = createAppointmentTypeTask; + } + + @Override + public void reset() { + createAppointmentTypeTask.createAppointmentTypes(); + } +} diff --git a/backend/measles-protection/src/main/java/de/eshg/measlesprotection/testhelper/ProtectionProcedureTestHelperController.java b/backend/measles-protection/src/main/java/de/eshg/measlesprotection/testhelper/ProtectionProcedureTestHelperController.java index bcde5f4e1ac5535d21a478a9cdc7e9972d5082b6..138ad3b46c5cc148099fe6d0c70d9be942baed07 100644 --- a/backend/measles-protection/src/main/java/de/eshg/measlesprotection/testhelper/ProtectionProcedureTestHelperController.java +++ b/backend/measles-protection/src/main/java/de/eshg/measlesprotection/testhelper/ProtectionProcedureTestHelperController.java @@ -12,6 +12,7 @@ import de.eshg.measlesprotection.api.draft.OpenProcedureResponse; import de.eshg.measlesprotection.config.MeaslesProtectionFeature; import de.eshg.measlesprotection.config.MeaslesProtectionFeatureToggle; import de.eshg.testhelper.ConditionalOnTestHelperEnabled; +import de.eshg.testhelper.DefaultTestHelperService; import de.eshg.testhelper.TestHelperController; import de.eshg.testhelper.api.PopulationRequest; import de.eshg.testhelper.environment.EnvironmentConfig; @@ -32,7 +33,7 @@ public class ProtectionProcedureTestHelperController extends TestHelperControlle private final MeaslesProtectionFeatureToggle measlesProtectionFeatureToggle; public ProtectionProcedureTestHelperController( - MeaslesProtectionTestHelperService testHelperService, + DefaultTestHelperService testHelperService, ProtectionProcedurePopulator populator, MeaslesProtectionFeatureToggle measlesProtectionFeatureToggle, AuditLogTestHelperService auditLogTestHelperService, diff --git a/backend/measles-protection/src/main/resources/migrations/0048_differentiate_between_previous_person_and_facility_file_state.xml b/backend/measles-protection/src/main/resources/migrations/0048_differentiate_between_previous_person_and_facility_file_state.xml new file mode 100644 index 0000000000000000000000000000000000000000..8729be621eb1c8a265e94bdcd231a834de5bd271 --- /dev/null +++ b/backend/measles-protection/src/main/resources/migrations/0048_differentiate_between_previous_person_and_facility_file_state.xml @@ -0,0 +1,21 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 cronn GmbH + SPDX-License-Identifier: AGPL-3.0-only +--> + +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> + <changeSet author="GA-Lotse" id="1738231823333-1"> + <renameColumn tableName="system_progress_entry" + oldColumnName="previous_file_state_id" + newColumnName="previous_person_file_state_id"/> + <addColumn tableName="system_progress_entry"> + <column name="previous_facility_file_state_id" type="UUID"/> + </addColumn> + <addUniqueConstraint columnNames="previous_facility_file_state_id" + constraintName="system_progress_entry_previous_facility_file_state_id_key" + tableName="system_progress_entry"/> + </changeSet> +</databaseChangeLog> diff --git a/backend/measles-protection/src/main/resources/migrations/0049_oms_appointment_type_extensions.xml b/backend/measles-protection/src/main/resources/migrations/0049_oms_appointment_type_extensions.xml new file mode 100644 index 0000000000000000000000000000000000000000..f74a51ac178f9e63ed3db7017ce572dd4b7938a0 --- /dev/null +++ b/backend/measles-protection/src/main/resources/migrations/0049_oms_appointment_type_extensions.xml @@ -0,0 +1,11 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 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="1739260647007-1"> + <ext:modifyPostgresEnumType name="appointmenttype" newValues="CAN_CHILD, CONSULTATION, ENTRY_LEVEL, HIV_STI_CONSULTATION, OFFICIAL_MEDICAL_SERVICE_LONG, OFFICIAL_MEDICAL_SERVICE_SHORT, PROOF_SUBMISSION, REGULAR_EXAMINATION, RESULTS_REVIEW, SEX_WORK, SPECIAL_NEEDS, VACCINATION"/> + </changeSet> +</databaseChangeLog> diff --git a/backend/measles-protection/src/main/resources/migrations/0050_add_shedlock.xml b/backend/measles-protection/src/main/resources/migrations/0050_add_shedlock.xml new file mode 100644 index 0000000000000000000000000000000000000000..1a9bb6576a544cb2669160e998f3e8740a995f99 --- /dev/null +++ b/backend/measles-protection/src/main/resources/migrations/0050_add_shedlock.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + Copyright 2025 cronn GmbH + SPDX-License-Identifier: AGPL-3.0-only +--> + +<databaseChangeLog + xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog + http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd"> + <changeSet author="GA-Lotse" id="1729865197316-1"> + <createTable tableName="shedlock"> + <column name="name" type="VARCHAR(64)"> + <constraints nullable="false" primaryKey="true" primaryKeyName="pk_shedlock"/> + </column> + <column name="lock_until" type="TIMESTAMP WITHOUT TIME ZONE"> + <constraints nullable="false"/> + </column> + <column name="locked_at" type="TIMESTAMP WITHOUT TIME ZONE"> + <constraints nullable="false"/> + </column> + <column name="locked_by" type="VARCHAR(255)"> + <constraints nullable="false"/> + </column> + </createTable> + </changeSet> +</databaseChangeLog> diff --git a/backend/measles-protection/src/main/resources/migrations/changelog.xml b/backend/measles-protection/src/main/resources/migrations/changelog.xml index df3deeecf28713814751020b6b6765edc0f8b7a7..ba78002b92cf71ea7594d8ca417767c35f6263f3 100644 --- a/backend/measles-protection/src/main/resources/migrations/changelog.xml +++ b/backend/measles-protection/src/main/resources/migrations/changelog.xml @@ -55,5 +55,8 @@ <include file="migrations/0045_add_previous_file_state_id_to_system_progress_entry.xml"/> <include file="migrations/0046_add_auditlog_entry.xml"/> <include file="migrations/0047_convert_duration_columns_to_interval.xml"/> + <include file="migrations/0048_differentiate_between_previous_person_and_facility_file_state.xml"/> + <include file="migrations/0049_oms_appointment_type_extensions.xml"/> + <include file="migrations/0050_add_shedlock.xml"/> </databaseChangeLog> diff --git a/backend/medical-registry/build.gradle b/backend/medical-registry/build.gradle index 550e09cd221c374740be8215d95fadecaadb3e63..cedb40ae3299f9009a72cdd28d0ff0fadb986a5c 100644 --- a/backend/medical-registry/build.gradle +++ b/backend/medical-registry/build.gradle @@ -19,6 +19,7 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' testImplementation "org.testcontainers:postgresql" + testImplementation 'net.javacrumbs.shedlock:shedlock-spring' testImplementation testFixtures(project(':business-module-persistence-commons')) testImplementation testFixtures(project(':lib-procedures')) testImplementation testFixtures(project(':lib-xlsx-import')) diff --git a/backend/medical-registry/gradle.lockfile b/backend/medical-registry/gradle.lockfile index ddf6f487af6e2cd25b6b8deead13f247479eaec8..ea5441d9c587dee87e732d67e358eb19408ec8c0 100644 --- a/backend/medical-registry/gradle.lockfile +++ b/backend/medical-registry/gradle.lockfile @@ -90,6 +90,9 @@ net.bytebuddy:byte-buddy:1.15.11=annotationProcessor,productionRuntimeClasspath, net.datafaker:datafaker:2.4.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.13.0=testCompileClasspath,testRuntimeClasspath net.java.dev.stax-utils:stax-utils:20070216=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-core:6.2.0=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.2.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-spring:6.2.0=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.logstash.logback:logstash-logback-encoder:8.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.5.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.minidev:json-smart:2.5.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/backend/medical-registry/openApi.json b/backend/medical-registry/openApi.json index 0ea7433fb2636d2274b8704bc5aaaa9f5c5339d0..45f5f1117a56580844448e14fab502b8b6193e1c 100644 --- a/backend/medical-registry/openApi.json +++ b/backend/medical-registry/openApi.json @@ -5967,7 +5967,11 @@ "type" : "integer", "format" : "int32" }, - "previousFileStateId" : { + "previousFacilityFileStateId" : { + "type" : "string", + "format" : "uuid" + }, + "previousPersonFileStateId" : { "type" : "string", "format" : "uuid" }, diff --git a/backend/medical-registry/src/main/java/de/eshg/medicalregistry/MedicalRegistryController.java b/backend/medical-registry/src/main/java/de/eshg/medicalregistry/MedicalRegistryController.java index f13638eafe4039ff7d2d851b17b28833f7ae7c37..56b357d473115348aaa5431755bed06d2d742d41 100644 --- a/backend/medical-registry/src/main/java/de/eshg/medicalregistry/MedicalRegistryController.java +++ b/backend/medical-registry/src/main/java/de/eshg/medicalregistry/MedicalRegistryController.java @@ -47,6 +47,7 @@ import de.eshg.medicalregistry.domain.model.TypeOfChange; import de.eshg.medicalregistry.featuretoggle.MedicalRegistryFeature; import de.eshg.medicalregistry.featuretoggle.MedicalRegistryFeatureToggle; import de.eshg.medicalregistry.mapper.EntryMapper; +import de.eshg.persistence.IntentionalWritingTransaction; import de.eshg.rest.service.error.BadRequestException; import de.eshg.rest.service.error.ErrorCode; import de.eshg.rest.service.error.NotFoundException; @@ -348,6 +349,7 @@ public class MedicalRegistryController { @GetMapping("/{procedureId}") @Transactional + @IntentionalWritingTransaction(reason = "Audit logging") @Operation(summary = "Get medical registry procedure by id.") public GetProcedureResponse getProcedure(@PathVariable("procedureId") UUID procedureId) { MedicalRegistryProcedure medicalRegistryProcedure = diff --git a/backend/medical-registry/src/main/java/de/eshg/medicalregistry/MedicalRegistryImportController.java b/backend/medical-registry/src/main/java/de/eshg/medicalregistry/MedicalRegistryImportController.java index 234bdaf1c340d810533b4fd6f6fd6f3b56443f68..06f29e41f668c8cbfae078e0686173aa6cbc4d37 100644 --- a/backend/medical-registry/src/main/java/de/eshg/medicalregistry/MedicalRegistryImportController.java +++ b/backend/medical-registry/src/main/java/de/eshg/medicalregistry/MedicalRegistryImportController.java @@ -18,6 +18,7 @@ import de.eshg.medicalregistry.importer.MedicalRegistryImporter; import de.eshg.rest.service.security.config.BaseUrls.MedicalRegistry; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.ValidatorFactory; import java.io.IOException; import java.time.Clock; import org.slf4j.Logger; @@ -48,17 +49,18 @@ public class MedicalRegistryImportController { private static final int IMPORTER_BATCH_SIZE = 1000; - private final Clock clock; private final MedicalRegistryService medicalRegistryService; private final MedicalRegistryProperties medicalRegistryProperties; + private final ValidatorFactory validatorFactory; public MedicalRegistryImportController( Clock clock, MedicalRegistryService medicalRegistryService, - MedicalRegistryProperties medicalRegistryProperties) { - this.clock = clock; + MedicalRegistryProperties medicalRegistryProperties, + ValidatorFactory validatorFactory) { this.medicalRegistryService = medicalRegistryService; this.medicalRegistryProperties = medicalRegistryProperties; + this.validatorFactory = validatorFactory; } @GetMapping(path = "/template", produces = CustomMediaTypes.APPLICATION_XLSX_VALUE) @@ -82,7 +84,11 @@ public class MedicalRegistryImportController { (sheet, actualColumns) -> { MedicalRegistryImporter importer = new MedicalRegistryImporter( - sheet, actualColumns, medicalRegistryService, clock, IMPORTER_BATCH_SIZE); + sheet, + actualColumns, + medicalRegistryService, + validatorFactory, + IMPORTER_BATCH_SIZE); return importer.process(); }); log.info( @@ -90,6 +96,7 @@ public class MedicalRegistryImportController { result.statistics().total(), result.statistics().created(), result.statistics().failed()); - return FileResponseUtil.mapImportResultToMultipartResponse(result, filename(clock)); + return FileResponseUtil.mapImportResultToMultipartResponse( + result, filename(validatorFactory.getClockProvider().getClock())); } } diff --git a/backend/medical-registry/src/main/java/de/eshg/medicalregistry/MedicalRegistryPublicCitizenController.java b/backend/medical-registry/src/main/java/de/eshg/medicalregistry/MedicalRegistryPublicCitizenController.java index 8f42481c72461f814cae21d73911d07189a0df71..69da5935f4e8f6ff633170790e2e49d41e17608a 100644 --- a/backend/medical-registry/src/main/java/de/eshg/medicalregistry/MedicalRegistryPublicCitizenController.java +++ b/backend/medical-registry/src/main/java/de/eshg/medicalregistry/MedicalRegistryPublicCitizenController.java @@ -5,6 +5,8 @@ package de.eshg.medicalregistry; +import static de.eshg.rest.service.PrivacyDocumentHelper.privacyNoticeAttachmentResponse; +import static de.eshg.rest.service.PrivacyDocumentHelper.privacyPolicyAttachmentResponse; import static de.eshg.rest.service.security.config.BaseUrls.MedicalRegistry.CITIZEN_PORTAL_ENDPOINT; import de.eshg.medicalregistry.config.MedicalRegistryProperties; @@ -13,12 +15,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; import java.io.UncheckedIOException; import java.net.MalformedURLException; import java.net.URI; -import java.nio.charset.StandardCharsets; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; -import org.springframework.http.ContentDisposition; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; @@ -54,26 +52,13 @@ public class MedicalRegistryPublicCitizenController { @Operation(summary = "Get the privacy-notice document.") @Transactional(readOnly = true) public ResponseEntity<Resource> getPrivacyNotice() { - return getPrivacyDocument(privacyNotice, "Datenschutz-Information.pdf"); + return privacyNoticeAttachmentResponse(privacyNotice); } @GetMapping(path = DOCUMENTS_PRIVACY_POLICY) @Operation(summary = "Get the privacy-policy document.") @Transactional(readOnly = true) public ResponseEntity<Resource> getPrivacyPolicy() { - return getPrivacyDocument(privacyPolicy, "Datenschutzerklaerung.pdf"); - } - - private static ResponseEntity<Resource> getPrivacyDocument( - Resource privacyDocument, String filename) { - return ResponseEntity.ok() - .header( - HttpHeaders.CONTENT_DISPOSITION, - ContentDisposition.attachment() - .filename(filename, StandardCharsets.UTF_8) - .build() - .toString()) - .contentType(MediaType.APPLICATION_PDF) - .body(privacyDocument); + return privacyPolicyAttachmentResponse(privacyPolicy); } } diff --git a/backend/medical-registry/src/main/java/de/eshg/medicalregistry/MedicalRegistryService.java b/backend/medical-registry/src/main/java/de/eshg/medicalregistry/MedicalRegistryService.java index 318304e4ab029fa9f43473aa4cb97546004ae8e9..6217d58e5b5f784ea768f7575738d784e28292da 100644 --- a/backend/medical-registry/src/main/java/de/eshg/medicalregistry/MedicalRegistryService.java +++ b/backend/medical-registry/src/main/java/de/eshg/medicalregistry/MedicalRegistryService.java @@ -7,16 +7,19 @@ package de.eshg.medicalregistry; import static de.eshg.medicalregistry.Validator.asMapper; import static de.eshg.medicalregistry.mapper.ProcedureMapper.mapToSystemProgressEntryType; +import static java.util.function.Predicate.not; import static org.springframework.data.domain.PageRequest.ofSize; import de.cronn.commons.lang.StreamUtil; import de.eshg.base.centralfile.api.person.GetPersonFileStateResponse; import de.eshg.lib.auditlog.AuditLogger; import de.eshg.lib.procedure.domain.factory.SystemProgressEntryFactory; +import de.eshg.lib.procedure.domain.model.BasicSystemProgressEntryType; import de.eshg.lib.procedure.domain.model.Image; import de.eshg.lib.procedure.domain.model.ImageMetaData; import de.eshg.lib.procedure.domain.model.ProcedureStatus; import de.eshg.lib.procedure.domain.model.ProcedureType; +import de.eshg.lib.procedure.domain.model.ProgressEntry; import de.eshg.lib.procedure.domain.model.SystemProgressEntry; import de.eshg.lib.procedure.domain.model.TriggerType; import de.eshg.lib.procedure.procedures.ProcedureDeletionService; @@ -46,10 +49,14 @@ import de.eshg.medicalregistry.domain.specification.MedicalRegistryProcedureOver import de.eshg.medicalregistry.importer.MedicalRegistryRow; import de.eshg.medicalregistry.mapper.CreationMapper; import de.eshg.medicalregistry.mapper.EntryMapper; +import de.eshg.medicalregistry.mapper.ProcedureMapper; import de.eshg.validation.ValidationUtil; import java.time.Clock; import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; +import java.util.Comparator; import java.util.EnumSet; import java.util.List; import java.util.Map; @@ -58,7 +65,9 @@ import java.util.Set; import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.apache.commons.collections4.ListUtils; +import org.hibernate.Hibernate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; @@ -69,6 +78,11 @@ public class MedicalRegistryService { private static final Logger log = LoggerFactory.getLogger(MedicalRegistryService.class); private static final Set<TypeOfChange> DEREGISTRATION_TYPE_OF_CHANGES = EnumSet.of(TypeOfChange.DEREGISTRATION, TypeOfChange.RELOCATION); + private static final Set<String> MEDICAL_REGISTRY_ENTRY_CHANGE_PROGRESS_ENTRY_TYPES = + Arrays.stream(TypeOfChange.values()) + .map(ProcedureMapper::mapToSystemProgressEntryType) + .map(Enum::name) + .collect(Collectors.toSet()); private final MedicalRegistryProcedureRepository medicalRegistryProcedureRepository; private final ProcedureDeletionService<MedicalRegistryProcedure> procedureDeletionService; @@ -188,14 +202,10 @@ public class MedicalRegistryService { copyValuesFromDraft(draftMedicalRegistryEntry, medicalRegistryEntry); updateOrConfirmProfessional( - draftMedicalRegistryEntry.getProfessional(), - medicalRegistryEntry, - professionalReferencePerson); + draftMedicalRegistryEntry, medicalRegistryEntry, professionalReferencePerson); updateOrConfirmPractice( - draftMedicalRegistryEntry.getRelatedFacilities(), - medicalRegistryEntry, - practiceReferenceFacility); + draftMedicalRegistryEntry, medicalRegistryEntry, practiceReferenceFacility); updateProfessionInformation(draftMedicalRegistryEntry, medicalRegistryEntry); @@ -268,16 +278,55 @@ public class MedicalRegistryService { } private void updateOrConfirmProfessional( - Professional sourceProfessional, + MedicalRegistryEntryChange sourceEntry, MedicalRegistryProcedure targetEntry, ProfessionalReferencePersonDto professionalReferencePerson) { - Professional targetProfessional = + Professional existingProfessional = targetEntry.getRelatedPersons().stream() .collect(StreamUtil.toSingleOptionalElement()) - .orElseGet(() -> addProfessionalToEntry(sourceProfessional, targetEntry)); + .orElse(null); - updateOrConfirmProfessional( - sourceProfessional, targetProfessional, professionalReferencePerson); + if (existingProfessional == null) { + Professional professional = sourceEntry.getProfessional(); + targetEntry.addRelatedPerson(professional); + updateOrConfirmProfessional(professional, professional, professionalReferencePerson); + } else { + UUID previousPersonCentralFileState = existingProfessional.getCentralFileStateId(); + + updateOrConfirmProfessional( + sourceEntry.getProfessional(), existingProfessional, professionalReferencePerson); + + documentPreviousPersonCentralFileStateIfNecessary( + sourceEntry, + previousPersonCentralFileState, + existingProfessional.getCentralFileStateId()); + } + } + + private static void documentPreviousPersonCentralFileStateIfNecessary( + MedicalRegistryEntryChange entry, + UUID previousPersonCentralFileState, + UUID newPersonCentralFileState) { + if (!newPersonCentralFileState.equals(previousPersonCentralFileState)) { + getLatestMedicalRegistryEntryChangeProgressEntry(entry) + .setPreviousPersonFileStateId(previousPersonCentralFileState); + } + } + + private static SystemProgressEntry getLatestMedicalRegistryEntryChangeProgressEntry( + MedicalRegistryEntryChange medicalRegistryEntryChange) { + return medicalRegistryEntryChange.getProgressEntries().stream() + .filter(SystemProgressEntry.class::isInstance) + .map(SystemProgressEntry.class::cast) + .filter(MedicalRegistryService::isMedicalRegistryEntryChangeProgressEntry) + .max(Comparator.comparing(ProgressEntry::getCreatedAt).thenComparing(ProgressEntry::getId)) + .orElseThrow(IllegalStateException::new); + } + + private static boolean isMedicalRegistryEntryChangeProgressEntry( + SystemProgressEntry progressEntry) { + return MEDICAL_REGISTRY_ENTRY_CHANGE_PROGRESS_ENTRY_TYPES.contains( + progressEntry.getSystemProgressEntryType()); } private void updateOrConfirmProfessional( @@ -291,38 +340,42 @@ public class MedicalRegistryService { sourceProfessional.getCentralFileStateId(), professionalReferencePerson)); } - private Professional addProfessionalToEntry( - Professional professional, MedicalRegistryProcedure entry) { - entry.addRelatedPerson(professional); - return professional; - } - private void updateOrConfirmPractice( - List<Practice> sourcePractices, + MedicalRegistryEntryChange sourceEntry, MedicalRegistryProcedure targetEntry, PracticeReferenceFacilityDto practiceReferenceFacility) { - sourcePractices.stream() + sourceEntry.getRelatedFacilities().stream() .collect(StreamUtil.toSingleOptionalElement()) .ifPresent( - sourcePractice -> - updateOrConfirmPractice(sourcePractice, targetEntry, practiceReferenceFacility)); + sourcePractice -> { + Practice existingPractice = + facilityService + .findTargetPractice( + targetEntry.getRelatedFacilities(), practiceReferenceFacility) + .orElse(null); + + if (existingPractice == null) { + targetEntry.addRelatedFacility(sourcePractice); + updateOrConfirmPractice(sourcePractice, sourcePractice, practiceReferenceFacility); + } else { + UUID previousFacilityFileState = existingPractice.getCentralFileStateId(); + updateOrConfirmPractice( + sourcePractice, existingPractice, practiceReferenceFacility); + + documentPreviousFacilityCentralFileStateIfNecessary( + sourceEntry, + previousFacilityFileState, + existingPractice.getCentralFileStateId()); + } + }); } - private void updateOrConfirmPractice( - Practice sourcePractice, - MedicalRegistryProcedure targetEntry, - PracticeReferenceFacilityDto practiceReferenceFacility) { - Practice targetPractice = - facilityService - .findTargetPractice(targetEntry.getRelatedFacilities(), practiceReferenceFacility) - .orElseGet(() -> addPracticeToEntry(sourcePractice, targetEntry)); - - updateOrConfirmPractice(sourcePractice, targetPractice, practiceReferenceFacility); - } - - private Practice addPracticeToEntry(Practice practice, MedicalRegistryProcedure target) { - target.addRelatedFacility(practice); - return practice; + private static void documentPreviousFacilityCentralFileStateIfNecessary( + MedicalRegistryEntryChange entry, UUID previousFacilityFileState, UUID newFacilityFileState) { + if (!newFacilityFileState.equals(previousFacilityFileState)) { + getLatestMedicalRegistryEntryChangeProgressEntry(entry) + .setPreviousFacilityFileStateId(previousFacilityFileState); + } } private void updateOrConfirmPractice( @@ -425,7 +478,43 @@ public class MedicalRegistryService { target.setRequestForWrittenConfirmation(source.isRequestForWrittenConfirmation()); getIsEmployeesEmployed(source).ifPresent(target::setEmployeesEmployed); - source.getProgressEntries().forEach(target::addProgressEntry); + replaceProgressEntries(target, merge(target.getProgressEntries(), source.getProgressEntries())); + } + + private static void replaceProgressEntries( + MedicalRegistryEntry target, List<ProgressEntry> progressEntries) { + target.getProgressEntries().clear(); + target.getProgressEntries().addAll(progressEntries); + } + + private static List<ProgressEntry> merge( + List<ProgressEntry> targetProgressEntries, List<ProgressEntry> sourceProgressEntries) { + List<ProgressEntry> finalProgressEntries = new ArrayList<>(); + + Stream.concat(targetProgressEntries.stream(), sourceProgressEntries.stream()) + .filter(MedicalRegistryService::isCreatedProgressEntry) + .min(Comparator.comparing(ProgressEntry::getCreatedAt).thenComparing(ProgressEntry::getId)) + .ifPresent(finalProgressEntries::add); + + finalProgressEntries.addAll( + targetProgressEntries.stream() + .filter(not(MedicalRegistryService::isCreatedProgressEntry)) + .toList()); + finalProgressEntries.addAll( + sourceProgressEntries.stream() + .filter(not(MedicalRegistryService::isCreatedProgressEntry)) + .toList()); + + return finalProgressEntries; + } + + private static boolean isCreatedProgressEntry(ProgressEntry progressEntry) { + if (Hibernate.unproxy(progressEntry) instanceof SystemProgressEntry systemProgressEntry) { + return BasicSystemProgressEntryType.CREATED + .name() + .equals(systemProgressEntry.getSystemProgressEntryType()); + } + return false; } public GetMedicalRegistryEntries getProceduresOverview( diff --git a/backend/medical-registry/src/main/java/de/eshg/medicalregistry/api/CreateApplicantDto.java b/backend/medical-registry/src/main/java/de/eshg/medicalregistry/api/CreateApplicantDto.java index c1e3faaa13915a2061c7855e32de9b606d923fcb..b5d77ba56a7cc6fc5311d34febbd01b2ac0da4f1 100644 --- a/backend/medical-registry/src/main/java/de/eshg/medicalregistry/api/CreateApplicantDto.java +++ b/backend/medical-registry/src/main/java/de/eshg/medicalregistry/api/CreateApplicantDto.java @@ -7,6 +7,7 @@ package de.eshg.medicalregistry.api; import de.eshg.base.GenderDto; import de.eshg.lib.common.CountryCode; +import de.eshg.validation.constraints.DateOfBirth; import de.eshg.validation.constraints.EmailAddressConstraint; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; @@ -20,7 +21,10 @@ public class CreateApplicantDto { private @NotNull GenderDto gender; private @NotNull @Size(min = 1, max = 80) String firstName; private @NotNull @Size(min = 1, max = 120) String lastName; - private @NotNull LocalDate dateOfBirth; + private @NotNull @DateOfBirth( + message = + "Das Alter muss mindestens {minAgeInclusive} und darf höchstens {maxAgeInclusive} Jahre betragen") + LocalDate dateOfBirth; private @Size(min = 1, max = 40) String nameAtBirth; private @NotNull @Size(min = 1, max = 50) String placeOfBirth; private @EmailAddressConstraint String emailAddress; diff --git a/backend/medical-registry/src/main/java/de/eshg/medicalregistry/importer/MedicalRegistryImporter.java b/backend/medical-registry/src/main/java/de/eshg/medicalregistry/importer/MedicalRegistryImporter.java index 104078f4edcac947ba2ee55758a494ccddce4a49..c1c19a57e75f210450d5fb4dede9909e2324eae2 100644 --- a/backend/medical-registry/src/main/java/de/eshg/medicalregistry/importer/MedicalRegistryImporter.java +++ b/backend/medical-registry/src/main/java/de/eshg/medicalregistry/importer/MedicalRegistryImporter.java @@ -10,7 +10,7 @@ import de.eshg.lib.xlsximport.ImportStatus; import de.eshg.lib.xlsximport.Importer; import de.eshg.lib.xlsximport.RowData; import de.eshg.medicalregistry.MedicalRegistryService; -import java.time.Clock; +import jakarta.validation.ValidatorFactory; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -29,11 +29,11 @@ public class MedicalRegistryImporter extends Importer<MedicalRegistryRow, Medica XSSFSheet sheet, List<MedicalRegistryColumn> actualColumns, MedicalRegistryService medicalRegistryService, - Clock clock, + ValidatorFactory validatorFactory, int batchSize) { super( sheet, - new MedicalRegistryRowReader(sheet, clock), + new MedicalRegistryRowReader(sheet, validatorFactory), new FeedbackColumnAccessor(actualColumns)); this.medicalRegistryService = medicalRegistryService; if (batchSize < 1 || batchSize > 10_000) { diff --git a/backend/medical-registry/src/main/java/de/eshg/medicalregistry/importer/MedicalRegistryRowReader.java b/backend/medical-registry/src/main/java/de/eshg/medicalregistry/importer/MedicalRegistryRowReader.java index 860eee79bcab171853885d93fceebcc7caeac73d..0bbb95c0e838d09f7d324a69186661aa4ae0f90e 100644 --- a/backend/medical-registry/src/main/java/de/eshg/medicalregistry/importer/MedicalRegistryRowReader.java +++ b/backend/medical-registry/src/main/java/de/eshg/medicalregistry/importer/MedicalRegistryRowReader.java @@ -18,13 +18,11 @@ import de.eshg.medicalregistry.api.CreatePracticeDto; import de.eshg.medicalregistry.api.CreateProfessionInformationDto; import de.eshg.medicalregistry.api.PracticeAddressDto; import jakarta.validation.ConstraintViolation; -import jakarta.validation.Validation; import jakarta.validation.ValidatorFactory; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import jakarta.validation.metadata.ConstraintDescriptor; -import java.time.Clock; import java.util.Arrays; import java.util.Set; import org.apache.poi.ss.usermodel.Cell; @@ -32,11 +30,15 @@ import org.apache.poi.ss.usermodel.Sheet; class MedicalRegistryRowReader extends RowReader<MedicalRegistryRow, MedicalRegistryColumn> { - private static final ValidatorFactory validatorFactory = - Validation.buildDefaultValidatorFactory(); + private final ValidatorFactory validatorFactory; - MedicalRegistryRowReader(Sheet sheet, Clock clock) { - super(sheet, Arrays.asList(MedicalRegistryColumn.values()), MedicalRegistryRow::new, clock); + MedicalRegistryRowReader(Sheet sheet, ValidatorFactory validatorFactory) { + super( + sheet, + Arrays.asList(MedicalRegistryColumn.values()), + MedicalRegistryRow::new, + validatorFactory.getClockProvider().getClock()); + this.validatorFactory = validatorFactory; } @Override diff --git a/backend/medical-registry/src/main/resources/MedicalRegistryImportTemplate.xlsx b/backend/medical-registry/src/main/resources/MedicalRegistryImportTemplate.xlsx index 19aeacaad162683a98f7305de873fa223aa4dcc2..6e518ce2b46ae4f06b31e2556db2983201d83b8c 100644 Binary files a/backend/medical-registry/src/main/resources/MedicalRegistryImportTemplate.xlsx and b/backend/medical-registry/src/main/resources/MedicalRegistryImportTemplate.xlsx differ diff --git a/backend/medical-registry/src/main/resources/migrations/0009_add_shedlock.xml b/backend/medical-registry/src/main/resources/migrations/0009_add_shedlock.xml new file mode 100644 index 0000000000000000000000000000000000000000..137f08977efcaddbfea22cbedc067313344b89f8 --- /dev/null +++ b/backend/medical-registry/src/main/resources/migrations/0009_add_shedlock.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + Copyright 2025 cronn GmbH + SPDX-License-Identifier: Apache-2.0 +--> + +<databaseChangeLog + xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog + http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd"> + <changeSet author="GA-Lotse" id="1729865197316-1"> + <createTable tableName="shedlock"> + <column name="name" type="VARCHAR(64)"> + <constraints nullable="false" primaryKey="true" primaryKeyName="pk_shedlock"/> + </column> + <column name="lock_until" type="TIMESTAMP WITHOUT TIME ZONE"> + <constraints nullable="false"/> + </column> + <column name="locked_at" type="TIMESTAMP WITHOUT TIME ZONE"> + <constraints nullable="false"/> + </column> + <column name="locked_by" type="VARCHAR(255)"> + <constraints nullable="false"/> + </column> + </createTable> + </changeSet> +</databaseChangeLog> diff --git a/backend/medical-registry/src/main/resources/migrations/0010_rename_previous_file_state.xml b/backend/medical-registry/src/main/resources/migrations/0010_rename_previous_file_state.xml new file mode 100644 index 0000000000000000000000000000000000000000..34abb256e08073f67d60ba852318586dba66a008 --- /dev/null +++ b/backend/medical-registry/src/main/resources/migrations/0010_rename_previous_file_state.xml @@ -0,0 +1,21 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 cronn GmbH + SPDX-License-Identifier: Apache-2.0 +--> + +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> + <changeSet author="GA-Lotse" id="1738231823333-1"> + <renameColumn tableName="system_progress_entry" + oldColumnName="previous_file_state_id" + newColumnName="previous_person_file_state_id"/> + <addColumn tableName="system_progress_entry"> + <column name="previous_facility_file_state_id" type="UUID"/> + </addColumn> + <addUniqueConstraint columnNames="previous_facility_file_state_id" + constraintName="system_progress_entry_previous_facility_file_state_id_key" + tableName="system_progress_entry"/> + </changeSet> +</databaseChangeLog> diff --git a/backend/medical-registry/src/main/resources/migrations/changelog.xml b/backend/medical-registry/src/main/resources/migrations/changelog.xml index 15661d472d3296884ace2de3258040eeee3848d0..419a36b44d5197d28408ea4b94df9fcccd2a416f 100644 --- a/backend/medical-registry/src/main/resources/migrations/changelog.xml +++ b/backend/medical-registry/src/main/resources/migrations/changelog.xml @@ -16,5 +16,7 @@ <include file="migrations/0006_add_countrycodes.xml"/> <include file="migrations/0007_add_previous_file_state_id_to_system_progress_entry.xml"/> <include file="migrations/0008_add_auditlog_entry.xml"/> + <include file="migrations/0009_add_shedlock.xml"/> + <include file="migrations/0010_rename_previous_file_state.xml"/> </databaseChangeLog> diff --git a/backend/official-medical-service/gradle.lockfile b/backend/official-medical-service/gradle.lockfile index 974f15435702970efe405ee4e675285820d09cba..6ddcff0cdd1409bfc437c7f9db64851a275a7b32 100644 --- a/backend/official-medical-service/gradle.lockfile +++ b/backend/official-medical-service/gradle.lockfile @@ -85,6 +85,9 @@ net.bytebuddy:byte-buddy:1.15.11=annotationProcessor,productionRuntimeClasspath, net.datafaker:datafaker:2.4.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.13.0=testCompileClasspath,testRuntimeClasspath net.java.dev.stax-utils:stax-utils:20070216=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-core:6.2.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.2.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-spring:6.2.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath net.logstash.logback:logstash-logback-encoder:8.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.5.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.minidev:json-smart:2.5.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/backend/official-medical-service/openApi.json b/backend/official-medical-service/openApi.json index fee8858e51531665a6e37081af8dab5758c571b6..2d65e96be3da5cddbf047d68fb52a8df6e068a6e 100644 --- a/backend/official-medical-service/openApi.json +++ b/backend/official-medical-service/openApi.json @@ -549,6 +549,25 @@ "tags" : [ "Archiving" ] } }, + "/citizen-public/concerns" : { + "get" : { + "operationId" : "getVisibleConcerns", + "responses" : { + "200" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/GetConcernsResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Get all available concerns for the online portal.", + "tags" : [ "CitizenPublic" ] + } + }, "/citizen-public/department-info" : { "get" : { "operationId" : "getDepartmentInfo", @@ -568,6 +587,41 @@ "tags" : [ "CitizenPublic" ] } }, + "/citizen-public/free-appointments" : { + "get" : { + "operationId" : "getFreeAppointmentsForCitizen", + "parameters" : [ { + "in" : "query", + "name" : "appointmentType", + "required" : true, + "schema" : { + "$ref" : "#/components/schemas/AppointmentType" + } + }, { + "in" : "query", + "name" : "earliestDate", + "required" : false, + "schema" : { + "type" : "string", + "format" : "date-time" + } + } ], + "responses" : { + "200" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/GetFreeAppointmentsResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Get free appointments for an appointment type.", + "tags" : [ "CitizenPublic" ] + } + }, "/citizen-public/opening-hours" : { "get" : { "operationId" : "getOpeningHours", @@ -587,6 +641,88 @@ "tags" : [ "CitizenPublic" ] } }, + "/citizen-public/privacy-notice" : { + "get" : { + "operationId" : "getPrivacyNotice", + "responses" : { + "200" : { + "content" : { + "application/json" : { + "schema" : { + "type" : "string", + "format" : "binary" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Get the privacy-notice document.", + "tags" : [ "CitizenPublic" ] + } + }, + "/citizen-public/privacy-policy" : { + "get" : { + "operationId" : "getPrivacyPolicy", + "responses" : { + "200" : { + "content" : { + "application/json" : { + "schema" : { + "type" : "string", + "format" : "binary" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Get the privacy-policy document.", + "tags" : [ "CitizenPublic" ] + } + }, + "/citizen-public/procedures" : { + "post" : { + "operationId" : "postCitizenProcedure", + "requestBody" : { + "content" : { + "multipart/form-data" : { + "schema" : { + "type" : "object", + "properties" : { + "files" : { + "type" : "array", + "items" : { + "type" : "string", + "format" : "binary" + } + }, + "request" : { + "$ref" : "#/components/schemas/PostCitizenProcedureRequest" + } + }, + "required" : [ "files", "request" ] + } + } + } + }, + "responses" : { + "200" : { + "content" : { + "application/json" : { + "schema" : { + "type" : "string", + "format" : "uuid" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Save a new citizen oms procedure.", + "tags" : [ "CitizenPublic" ] + } + }, "/employee/appointments/{id}/book" : { "patch" : { "operationId" : "bookAppointment", @@ -740,6 +876,13 @@ "type" : "string", "format" : "uuid" } + }, { + "in" : "query", + "name" : "note", + "required" : false, + "schema" : { + "type" : "string" + } } ], "requestBody" : { "content" : { @@ -3828,7 +3971,7 @@ }, "AppointmentType" : { "type" : "string", - "enum" : [ "CONSULTATION", "VACCINATION", "REGULAR_EXAMINATION", "CAN_CHILD", "ENTRY_LEVEL", "SPECIAL_NEEDS", "PROOF_SUBMISSION", "HIV_STI_CONSULTATION", "SEX_WORK", "RESULTS_REVIEW", "OFFICIAL_MEDICAL_SERVICE" ] + "enum" : [ "CONSULTATION", "VACCINATION", "REGULAR_EXAMINATION", "CAN_CHILD", "ENTRY_LEVEL", "SPECIAL_NEEDS", "PROOF_SUBMISSION", "HIV_STI_CONSULTATION", "SEX_WORK", "RESULTS_REVIEW", "OFFICIAL_MEDICAL_SERVICE_SHORT", "OFFICIAL_MEDICAL_SERVICE_LONG" ] }, "AppointmentTypeConfig" : { "required" : [ "appointmentTypeDto", "id", "standardDurationInMinutes" ], @@ -4078,21 +4221,18 @@ } }, "Concern" : { - "required" : [ "categoryNameDe", "categoryNameEn", "descriptionDe", "descriptionEn", "highPriority", "nameDe", "nameEn", "version" ], + "required" : [ "categoryNameDe", "categoryNameEn", "highPriority", "nameDe", "version", "visibleInOnlinePortal" ], "type" : "object", "properties" : { + "appointmentType" : { + "$ref" : "#/components/schemas/AppointmentType" + }, "categoryNameDe" : { "type" : "string" }, "categoryNameEn" : { "type" : "string" }, - "descriptionDe" : { - "type" : "string" - }, - "descriptionEn" : { - "type" : "string" - }, "highPriority" : { "type" : "boolean" }, @@ -4105,6 +4245,9 @@ "version" : { "type" : "integer", "format" : "int64" + }, + "visibleInOnlinePortal" : { + "type" : "boolean" } } }, @@ -4127,14 +4270,11 @@ } }, "ConcernConfig" : { - "required" : [ "descriptionDe", "descriptionEn", "highPriority", "nameDe", "nameEn" ], + "required" : [ "highPriority", "nameDe", "visibleInOnlinePortal" ], "type" : "object", "properties" : { - "descriptionDe" : { - "type" : "string" - }, - "descriptionEn" : { - "type" : "string" + "appointmentType" : { + "$ref" : "#/components/schemas/AppointmentType" }, "highPriority" : { "type" : "boolean" @@ -4144,12 +4284,15 @@ }, "nameEn" : { "type" : "string" + }, + "visibleInOnlinePortal" : { + "type" : "boolean" } } }, "ConcernTestDataConfig" : { "type" : "string", - "enum" : [ "EXAMINATION_ELIGIBILITY", "CERTIFICATE_FOR_CALL_OF_DUTY", "PRIORITIZATION_OF_CIVIL_SERVANTS", "EARLY_RETIREMENT", "REVIEW_OF_LONGER_SICK_NOTES" ] + "enum" : [ "DRUG_SCREENING", "REINTEGRATION", "ATTESTATION", "ASSISTANCE", "CERTIFICATE_FOR_CALL_OF_DUTY_FREE", "CERTIFICATE_FOR_CALL_OF_DUTY_PAID", "CERTIFICATE_FOR_CALL_OF_DUTY_ADDITION", "CERTIFICATE_FOR_CALL_OF_DUTY_CONTRADICTION", "OPERATIONAL_CAPABILITY", "RECRUITMENT_FREE", "RECRUITMENT_PAID", "CIVIL_SERVANTS_ON_PROBATION", "CIVIL_SERVANTS", "PROBATIONARY_CIVIL_SERVANTS", "TEMPORARY_CIVIL_SERVANTS", "RECRUITMENT_CONTRADICTION", "RECRUITMENT_FIRE_DEPARTMENT", "HOURLY_DISCOUNT", "ACCIDENT_REPORT_FREE", "ACCIDENT_REPORT_PAID", "RESCUE_SERVICES_LAW", "PEDIGREE_REPORT", "ADOPTION", "WORK_EARNING_CAPACITY", "INVESTIGATION_ASSIGNMENT", "FOSTER_CHILD", "SOCIAL_MEDICINE", "S_HANDICAPPED", "PRESELECTION_FIRE_DEPARTMENT", "PRESELECTION_FIRE_DEPARTMENT_EYESIGHT", "CONTRADICTION", "TAX_OFFICE", "EXAMINATION_ELIGIBILITY", "MISCELLANEOUS" ] }, "ContactDetails" : { "required" : [ "contactType", "salutation" ], @@ -4460,6 +4603,9 @@ }, "uploadInCitizenPortal" : { "type" : "boolean" + }, + "uploadedBy" : { + "$ref" : "#/components/schemas/DocumentUploadedBy" } } }, @@ -4494,6 +4640,10 @@ "type" : "string", "enum" : [ "MISSING", "SUBMITTED", "REJECTED", "ACCEPTED" ] }, + "DocumentUploadedBy" : { + "type" : "string", + "enum" : [ "INTERN", "EXTERN" ] + }, "DomesticAddress" : { "required" : [ "city", "country", "postalCode", "street" ], "type" : "object", @@ -6491,6 +6641,21 @@ } } }, + "PostCitizenProcedureRequest" : { + "required" : [ "affectedPerson", "appointment", "concern" ], + "type" : "object", + "properties" : { + "affectedPerson" : { + "$ref" : "#/components/schemas/AffectedPerson" + }, + "appointment" : { + "$ref" : "#/components/schemas/PostOmsAppointmentRequest" + }, + "concern" : { + "$ref" : "#/components/schemas/Concern" + } + } + }, "PostDocumentRequest" : { "required" : [ "documentTypeDe", "mandatoryDocument", "uploadInCitizenPortal" ], "type" : "object", @@ -6565,8 +6730,28 @@ } } }, + "PostPopulateCitizenProcedureRequest" : { + "required" : [ "affectedPerson", "appointment", "concern", "files" ], + "type" : "object", + "properties" : { + "affectedPerson" : { + "$ref" : "#/components/schemas/AffectedPerson" + }, + "appointment" : { + "$ref" : "#/components/schemas/AppointmentPopulation" + }, + "concern" : { + "$ref" : "#/components/schemas/ConcernTestDataConfig" + }, + "files" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/FileTestDataConfig" + } + } + } + }, "PostPopulateProcedureRequest" : { - "required" : [ "procedureData" ], "type" : "object", "properties" : { "appointments" : { @@ -6612,6 +6797,9 @@ "procedureData" : { "$ref" : "#/components/schemas/PostEmployeeOmsProcedureRequest" }, + "procedureDataCitizen" : { + "$ref" : "#/components/schemas/PostPopulateCitizenProcedureRequest" + }, "sendEmailNotifications" : { "type" : "boolean" }, @@ -6976,7 +7164,11 @@ "type" : "integer", "format" : "int32" }, - "previousFileStateId" : { + "previousFacilityFileStateId" : { + "type" : "string", + "format" : "uuid" + }, + "previousPersonFileStateId" : { "type" : "string", "format" : "uuid" }, diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/appointment/OmsAppointmentService.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/appointment/OmsAppointmentService.java index 99ae7a29f9a92840a13672e4223480e2a7cb5a4a..9e3256a74efcc65179de4d82893e649f422b6127 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/appointment/OmsAppointmentService.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/appointment/OmsAppointmentService.java @@ -5,6 +5,9 @@ package de.eshg.officialmedicalservice.appointment; +import static de.eshg.lib.appointmentblock.api.AppointmentTypeDto.OFFICIAL_MEDICAL_SERVICE_LONG; +import static de.eshg.lib.appointmentblock.api.AppointmentTypeDto.OFFICIAL_MEDICAL_SERVICE_SHORT; + import de.eshg.lib.appointmentblock.AppointmentBlockSlotUtil; import de.eshg.lib.appointmentblock.api.AppointmentTypeDto; import de.eshg.lib.appointmentblock.persistence.AppointmentType; @@ -35,7 +38,7 @@ public class OmsAppointmentService { private final AppointmentBlockSlotUtil appointmentBlockSlotUtil; private static final List<AppointmentTypeDto> supportedAppointmentTypes = - List.of(AppointmentTypeDto.OFFICIAL_MEDICAL_SERVICE); + List.of(OFFICIAL_MEDICAL_SERVICE_SHORT, OFFICIAL_MEDICAL_SERVICE_LONG); private final ProgressEntryService progressEntryService; public OmsAppointmentService( @@ -63,6 +66,10 @@ public class OmsAppointmentService { throw new BadRequestException("Unsupported appointment type."); } + if (procedureHasOpenAppointment(procedure)) { + throw new BadRequestException("Procedure already has an open appointment"); + } + AppointmentType appointmentType = omsAppointmentMapper.toDomainType(request.appointmentType()); // create bookable appointment @@ -87,6 +94,26 @@ public class OmsAppointmentService { return appointment.getExternalId(); } + @Transactional + public void addAppointmentCitizen(OmsProcedure procedure, PostOmsAppointmentRequest request) { + if (procedure.isFinalized()) { + throw new BadRequestException("Procedure already closed"); + } + if (!supportedAppointmentTypes.contains(request.appointmentType())) { + throw new BadRequestException("Unsupported appointment type."); + } + + AppointmentType appointmentType = omsAppointmentMapper.toDomainType(request.appointmentType()); + + OmsAppointment appointment = new OmsAppointment(appointmentType); + appointment.setProcedure(procedure); + procedure.getAppointments().add(appointment); + + processBooking(request.bookingInfo(), appointment); + + omsAppointmentRepository.save(appointment); + } + @Transactional public void bookAppointmentEmployee(UUID appointmentId, BookingInfoDto request) { OmsAppointment appointment = loadAppointment(appointmentId); @@ -157,6 +184,11 @@ public class OmsAppointmentService { appointment.setAppointmentState(AppointmentState.CLOSED); } + private boolean procedureHasOpenAppointment(OmsProcedure omsProcedure) { + return omsProcedure.getAppointments().stream() + .anyMatch(appointment -> appointment.getAppointmentState() == AppointmentState.OPEN); + } + private void processBooking(BookingInfoDto bookingInfo, OmsAppointment appointment) { BookingTypeDto bookingTypeDto = bookingInfo.bookingType(); Instant start = bookingInfo.start(); diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/citizenpublic/CitizenProcedureService.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/citizenpublic/CitizenProcedureService.java new file mode 100644 index 0000000000000000000000000000000000000000..91b590eb5f950011ced1a6cdc5ce0a12058b99d4 --- /dev/null +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/citizenpublic/CitizenProcedureService.java @@ -0,0 +1,65 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.officialmedicalservice.citizenpublic; + +import de.eshg.base.centralfile.api.person.AddPersonFileStateResponse; +import de.eshg.officialmedicalservice.appointment.OmsAppointmentService; +import de.eshg.officialmedicalservice.concern.ConcernMapper; +import de.eshg.officialmedicalservice.document.OmsDocumentService; +import de.eshg.officialmedicalservice.person.PersonClient; +import de.eshg.officialmedicalservice.person.PersonMapper; +import de.eshg.officialmedicalservice.procedure.OmsProcedureOverviewMapper; +import de.eshg.officialmedicalservice.procedure.api.PostCitizenProcedureRequest; +import de.eshg.officialmedicalservice.procedure.persistence.entity.OmsProcedure; +import de.eshg.officialmedicalservice.procedure.persistence.entity.OmsProcedureRepository; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +public class CitizenProcedureService { + private final OmsAppointmentService omsAppointmentService; + private final PersonClient personClient; + private final OmsProcedureOverviewMapper omsProcedureOverviewMapper; + private final OmsProcedureRepository omsProcedureRepository; + private final OmsDocumentService omsDocumentService; + + public CitizenProcedureService( + OmsAppointmentService appointmentService, + PersonClient personClient, + OmsProcedureOverviewMapper omsProcedureOverviewMapper, + OmsProcedureRepository omsProcedureRepository, + OmsDocumentService omsDocumentService) { + this.omsAppointmentService = appointmentService; + this.personClient = personClient; + this.omsProcedureOverviewMapper = omsProcedureOverviewMapper; + this.omsProcedureRepository = omsProcedureRepository; + this.omsDocumentService = omsDocumentService; + } + + @Transactional + public UUID createCitizenProcedure( + PostCitizenProcedureRequest request, List<MultipartFile> files) { + AddPersonFileStateResponse affectedPersonBaseResponse = + personClient.addPersonFromExternalSource( + PersonMapper.mapToExternalAddPersonFileStateRequest(request.affectedPerson())); + + OmsProcedure procedure = + omsProcedureOverviewMapper.toDomainType(null, affectedPersonBaseResponse, null); + + omsProcedureRepository.save(procedure); + + omsAppointmentService.addAppointmentCitizen(procedure, request.appointment()); + + procedure.setConcern(ConcernMapper.mapToEntity(request.concern())); + + omsDocumentService.addLetterOfAssignmentCitizen(procedure, files); + + return procedure.getExternalId(); + } +} diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/citizenpublic/CitizenPublicController.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/citizenpublic/CitizenPublicController.java index b226f62bffb5d89e2748c08a325aea7432f7f51c..3eb6ae60252e320db61445fc861b33fab523f1a9 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/citizenpublic/CitizenPublicController.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/citizenpublic/CitizenPublicController.java @@ -5,16 +5,41 @@ package de.eshg.officialmedicalservice.citizenpublic; +import static de.eshg.rest.service.PrivacyDocumentHelper.privacyNoticeAttachmentResponse; +import static de.eshg.rest.service.PrivacyDocumentHelper.privacyPolicyAttachmentResponse; +import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; + import de.eshg.base.department.GetDepartmentInfoResponse; +import de.eshg.lib.appointmentblock.AppointmentBlockService; +import de.eshg.lib.appointmentblock.MappingUtil; +import de.eshg.lib.appointmentblock.api.AppointmentDto; +import de.eshg.lib.appointmentblock.api.AppointmentTypeDto; +import de.eshg.lib.appointmentblock.api.GetFreeAppointmentsResponse; +import de.eshg.lib.appointmentblock.persistence.AppointmentType; import de.eshg.officialmedicalservice.citizenpublic.api.GetOpeningHoursResponse; +import de.eshg.officialmedicalservice.concern.ConcernService; +import de.eshg.officialmedicalservice.procedure.api.GetConcernsResponse; +import de.eshg.officialmedicalservice.procedure.api.PostCitizenProcedureRequest; import de.eshg.rest.service.security.config.BaseUrls; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.time.Clock; +import java.time.Instant; import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping( @@ -24,18 +49,44 @@ import org.springframework.web.bind.annotation.RestController; public class CitizenPublicController { public static final String BASE_URL = BaseUrls.OfficialMedicalService.CITIZEN_PUBLIC_API; + public static final String OPENING_HOURS_URL = "/opening-hours"; + public static final String DEPARTMENT_INFO_URL = "/department-info"; + public static final String PROCEDURES_URL = "/procedures"; + public static final String FREE_APPOINTMENTS_URL = "/free-appointments"; + public static final String PRIVACY_NOTICE_URL = "/privacy-notice"; + public static final String PRIVACY_POLICY_URL = "/privacy-policy"; + public static final String CONCERNS_URL = "/concerns"; private final OpeningHoursProperties openingHoursProperties; private final DepartmentInfoService departmentInfoService; + private final CitizenProcedureService citizenProcedureService; + private final AppointmentBlockService appointmentBlockService; + private final Clock clock; + private final Resource privacyNotice; + private final Resource privacyPolicy; + private final ConcernService concernService; public CitizenPublicController( - OpeningHoursProperties openingHoursProperties, DepartmentInfoService departmentInfoService) { + OpeningHoursProperties openingHoursProperties, + DepartmentInfoService departmentInfoService, + CitizenProcedureService citizenProcedureService, + AppointmentBlockService appointmentBlockService, + Clock clock, + @Value("${de.eshg.official-medical-service.privacy-notice-location}") Resource privacyNotice, + @Value("${de.eshg.official-medical-service.privacy-policy-location}") Resource privacyPolicy, + ConcernService concernService) { this.openingHoursProperties = openingHoursProperties; this.departmentInfoService = departmentInfoService; + this.citizenProcedureService = citizenProcedureService; + this.appointmentBlockService = appointmentBlockService; + this.clock = clock; + this.privacyNotice = privacyNotice; + this.privacyPolicy = privacyPolicy; + this.concernService = concernService; } @Operation(summary = "Get opening hours.") - @GetMapping("/opening-hours") + @GetMapping(path = OPENING_HOURS_URL) public GetOpeningHoursResponse getOpeningHours() { return new GetOpeningHoursResponse( @@ -46,8 +97,53 @@ public class CitizenPublicController { } @Operation(summary = "Get department info.") - @GetMapping("/department-info") + @GetMapping(path = DEPARTMENT_INFO_URL) public GetDepartmentInfoResponse getDepartmentInfo() { return departmentInfoService.getDepartmentInfo(); } + + @Operation(summary = "Save a new citizen oms procedure.") + @PostMapping(path = PROCEDURES_URL, consumes = MULTIPART_FORM_DATA_VALUE) + public UUID postCitizenProcedure( + @RequestPart(name = "request") @Valid PostCitizenProcedureRequest request, + @RequestPart(name = "files") List<MultipartFile> files) { + return citizenProcedureService.createCitizenProcedure(request, files); + } + + @Operation(summary = "Get free appointments for an appointment type.") + @GetMapping(path = FREE_APPOINTMENTS_URL) + public GetFreeAppointmentsResponse getFreeAppointmentsForCitizen( + @RequestParam(name = "appointmentType") AppointmentTypeDto appointmentType, + @RequestParam(name = "earliestDate", required = false) Instant earliestDate) { + if (earliestDate != null && earliestDate.isBefore(Instant.now(clock))) { + earliestDate = Instant.now(clock); + } + List<AppointmentDto> appointments = + appointmentBlockService.getFreeAppointments( + earliestDate, + null, + MappingUtil.mapEnum(AppointmentType.class, appointmentType), + null, + null); + + return new GetFreeAppointmentsResponse(appointments); + } + + @Operation(summary = "Get the privacy-notice document.") + @GetMapping(path = PRIVACY_NOTICE_URL) + public ResponseEntity<Resource> getPrivacyNotice() { + return privacyNoticeAttachmentResponse(privacyNotice); + } + + @Operation(summary = "Get the privacy-policy document.") + @GetMapping(path = PRIVACY_POLICY_URL) + public ResponseEntity<Resource> getPrivacyPolicy() { + return privacyPolicyAttachmentResponse(privacyPolicy); + } + + @Operation(summary = "Get all available concerns for the online portal.") + @GetMapping(path = CONCERNS_URL) + public GetConcernsResponse getVisibleConcerns() { + return concernService.getConcernsVisibleInOnlinePortal(); + } } diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/concern/ConcernMapper.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/concern/ConcernMapper.java index 8de432cc9e5ffd5a55a1d4d4bb9cb4ff6b1f8bbe..26a94a81edb16567352e7db6b408dcb45de258f6 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/concern/ConcernMapper.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/concern/ConcernMapper.java @@ -5,6 +5,8 @@ package de.eshg.officialmedicalservice.concern; +import de.eshg.lib.appointmentblock.AppointmentTypeMapper; +import de.eshg.lib.appointmentblock.api.AppointmentTypeDto; import de.eshg.officialmedicalservice.procedure.api.ConcernCategoryConfigDto; import de.eshg.officialmedicalservice.procedure.api.ConcernConfigDto; import de.eshg.officialmedicalservice.procedure.api.ConcernDto; @@ -30,12 +32,23 @@ public class ConcernMapper { } public static ConcernConfigDto mapToConcernConfigDto(Map<String, Object> yaml) { + String concernEn = + yaml.get("concern_en") != null ? String.valueOf(yaml.get("concern_en")) : null; + AppointmentTypeDto appointmentType = + yaml.get("appointment_type") != null + ? AppointmentTypeDto.valueOf(String.valueOf(yaml.get("appointment_type"))) + : null; + boolean visibleInOnlinePortal = Boolean.TRUE.equals(yaml.get("online_portal_visibility")); + if (visibleInOnlinePortal && (concernEn == null || appointmentType == null)) { + throw new RuntimeException( + "An english concern name and appointment type must be specified when visible in online portal"); + } return new ConcernConfigDto( String.valueOf(yaml.get("concern_de")), - String.valueOf(yaml.get("concern_en")), - String.valueOf(yaml.get("description_de")), - String.valueOf(yaml.get("description_en")), - Boolean.TRUE.equals(yaml.get("high_priority"))); + concernEn, + Boolean.TRUE.equals(yaml.get("high_priority")), + appointmentType, + visibleInOnlinePortal); } public static ConcernDto mapToConcernDto(Concern concern) { @@ -46,11 +59,13 @@ public class ConcernMapper { concern.getVersion(), concern.getNameDe(), concern.getNameEn(), - concern.getDescriptionDe(), - concern.getDescriptionEn(), concern.isHighPriority(), concern.getCategoryNameDe(), - concern.getCategoryNameEn()); + concern.getCategoryNameEn(), + concern.getAppointmentType() != null + ? AppointmentTypeMapper.toInterfaceType(concern.getAppointmentType()) + : null, + concern.isVisibleInOnlinePortal()); } public static Concern mapToEntity(ConcernDto concernDto) { @@ -62,11 +77,14 @@ public class ConcernMapper { public static void mapOntoExistingEntity(ConcernDto concernDto, Concern concern) { concern.setNameDe(concernDto.nameDe()); concern.setNameEn(concernDto.nameEn()); - concern.setDescriptionDe(concernDto.descriptionDe()); - concern.setDescriptionEn(concernDto.descriptionEn()); concern.setHighPriority(concernDto.highPriority()); concern.setCategoryNameDe(concernDto.categoryNameDe()); concern.setCategoryNameEn(concernDto.categoryNameEn()); + concern.setAppointmentType( + concernDto.appointmentType() != null + ? AppointmentTypeMapper.toDomainType(concernDto.appointmentType()) + : null); + concern.setVisibleInOnlinePortal(concernDto.visibleInOnlinePortal()); } public static ConcernDto mapConcernConfigToConcernDto( @@ -77,10 +95,10 @@ public class ConcernMapper { version, concernConfigDto.nameDe(), concernConfigDto.nameEn(), - concernConfigDto.descriptionDe(), - concernConfigDto.descriptionEn(), concernConfigDto.highPriority(), concernCategoryConfigDto.nameDe(), - concernCategoryConfigDto.nameEn()); + concernCategoryConfigDto.nameEn(), + concernConfigDto.appointmentType(), + concernConfigDto.visibleInOnlinePortal()); } } diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/concern/ConcernService.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/concern/ConcernService.java index a4a8f1b7b94db1fb1eaffd50f75cb6d5aa2ec1b2..035b4724e7bfe93467ff6addffeae794c4cb8db6 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/concern/ConcernService.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/concern/ConcernService.java @@ -5,6 +5,8 @@ package de.eshg.officialmedicalservice.concern; +import de.eshg.officialmedicalservice.procedure.api.ConcernCategoryConfigDto; +import de.eshg.officialmedicalservice.procedure.api.ConcernConfigDto; import de.eshg.officialmedicalservice.procedure.api.GetConcernsResponse; import de.eshg.rest.service.error.BadRequestException; import de.eshg.rest.service.error.ErrorCode; @@ -38,4 +40,22 @@ public class ConcernService { "Cannot read concerns config file: " + concernsResource.getFilename()); } } + + public GetConcernsResponse getConcernsVisibleInOnlinePortal() { + List<ConcernCategoryConfigDto> filteredCategories = + getConcerns().categories().stream() + .map( + category -> + new ConcernCategoryConfigDto( + category.nameDe(), + category.nameEn(), + category.concerns().stream() + .filter(ConcernConfigDto::visibleInOnlinePortal) + .toList())) + .filter( // filter out categories without concerns + category -> !category.concerns().isEmpty()) + .toList(); + + return new GetConcernsResponse(filteredCategories); + } } diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/OmsDocumentController.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/OmsDocumentController.java index b3224e9ea795a72ee91790a2b2a567b70b28de16..68dd9e9c50743a466ed9445780cd0e245b623ec6 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/OmsDocumentController.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/OmsDocumentController.java @@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @@ -56,8 +57,9 @@ public class OmsDocumentController { @Operation(summary = "Completes file upload of one oms document") public void patchCompleteDocumentFileUpload( @PathVariable("id") UUID documentId, - @RequestPart(value = "files") List<MultipartFile> files) { - omsDocumentService.completeDocumentFileUploadEmployee(documentId, files); + @RequestPart(value = "files") List<MultipartFile> files, + @RequestParam(name = "note", required = false) String note) { + omsDocumentService.completeDocumentFileUploadEmployee(documentId, files, note); } @DeleteMapping(path = DOCUMENT_URL + "/{id}") diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/OmsDocumentMapper.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/OmsDocumentMapper.java index 4e87f1c6a3a2a4c176aec96c444cb60bfec9fafa..7dbe92dfe6a886c3053f2f049457cfedfba2f966 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/OmsDocumentMapper.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/OmsDocumentMapper.java @@ -7,6 +7,7 @@ package de.eshg.officialmedicalservice.document; import de.eshg.officialmedicalservice.document.api.DocumentDto; import de.eshg.officialmedicalservice.document.api.DocumentStatusDto; +import de.eshg.officialmedicalservice.document.api.DocumentUploadedByDto; import de.eshg.officialmedicalservice.document.persistence.entity.OmsDocument; import de.eshg.officialmedicalservice.document.persistence.entity.OmsDocumentStatus; import de.eshg.officialmedicalservice.file.OmsFileMapper; @@ -49,7 +50,10 @@ public class OmsDocumentMapper { document.getNote(), document.isMandatoryDocument(), document.isUploadInCitizenPortal(), - document.getReasonForRejection()); + document.getReasonForRejection(), + document.getUploadedBy() != null + ? DocumentUploadedByDto.valueOf(document.getUploadedBy().name()) + : null); } public DocumentStatusDto toInterfaceType(OmsDocumentStatus documentStatus) { diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/OmsDocumentService.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/OmsDocumentService.java index 10987c63bee05d45f92d127c72a8f770dd31b537..1416cb1efab1cfb090d24f698d5b1c1ea38434e5 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/OmsDocumentService.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/OmsDocumentService.java @@ -15,6 +15,7 @@ import de.eshg.officialmedicalservice.document.api.PatchDocumentInformationReque import de.eshg.officialmedicalservice.document.api.PatchDocumentNoteRequest; import de.eshg.officialmedicalservice.document.api.PatchDocumentReviewRequest; import de.eshg.officialmedicalservice.document.api.PostDocumentRequest; +import de.eshg.officialmedicalservice.document.persistence.entity.DocumentUploadedBy; import de.eshg.officialmedicalservice.document.persistence.entity.OmsDocument; import de.eshg.officialmedicalservice.document.persistence.entity.OmsDocumentRepository; import de.eshg.officialmedicalservice.document.persistence.entity.OmsDocumentStatus; @@ -82,6 +83,7 @@ public class OmsDocumentService { document.setDocumentStatus(OmsDocumentStatus.ACCEPTED); document.setLastDocumentUpload(Instant.now(clock)); document.setNote(note); + document.setUploadedBy(DocumentUploadedBy.INTERN); } else { document.setDocumentStatus(OmsDocumentStatus.MISSING); } @@ -108,6 +110,24 @@ public class OmsDocumentService { return document.getExternalId(); } + @Transactional + public void addLetterOfAssignmentCitizen(OmsProcedure procedure, List<MultipartFile> files) { + validateFileTypes(files); + + OmsDocument document = new OmsDocument(); + document.setDocumentTypeDe("Auftragsschreiben"); + document.setDocumentStatus(OmsDocumentStatus.SUBMITTED); + document.setLastDocumentUpload(Instant.now(clock)); + document.setMandatoryDocument(true); + document.setUploadInCitizenPortal(true); + document.setUploadedBy(DocumentUploadedBy.EXTERN); + + document.setOmsProcedure(procedure); + omsDocumentRepository.save(document); + + saveFiles(document, files); + } + @Transactional public void updateDocumentInformationEmployee( UUID documentId, PatchDocumentInformationRequest request) { @@ -139,7 +159,8 @@ public class OmsDocumentService { } @Transactional - public void completeDocumentFileUploadEmployee(UUID documentId, List<MultipartFile> files) { + public void completeDocumentFileUploadEmployee( + UUID documentId, List<MultipartFile> files, String note) { OmsDocument omsDocument = loadOmsDocument(documentId); if (omsDocument.getOmsProcedure().isFinalized()) { @@ -155,6 +176,8 @@ public class OmsDocumentService { omsDocument.setReasonForRejection(null); omsDocument.setDocumentStatus(OmsDocumentStatus.ACCEPTED); omsDocument.setLastDocumentUpload(Instant.now(clock)); + omsDocument.setNote(note); + omsDocument.setUploadedBy(DocumentUploadedBy.INTERN); OmsProcedure omsProcedure = omsDocument.getOmsProcedure(); progressEntryService.createProgressEntryCompleteDocumentFileUploadEmployee( @@ -215,6 +238,7 @@ public class OmsDocumentService { throw new BadRequestException("reasonForRejection must not be blank"); } deleteAllFiles(document); + document.setUploadedBy(null); document.setReasonForRejection(request.reasonForRejection()); document.setDocumentStatus(OmsDocumentStatus.REJECTED); } diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/api/DocumentDto.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/api/DocumentDto.java index daa9376874b073b2798a7a40bbc7a2925208496b..85c6a90cd209c13b3959fc0d2edf4161f381a36a 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/api/DocumentDto.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/api/DocumentDto.java @@ -26,4 +26,5 @@ public record DocumentDto( String note, @NotNull boolean mandatoryDocument, @NotNull boolean uploadInCitizenPortal, - String reasonForRejection) {} + String reasonForRejection, + DocumentUploadedByDto uploadedBy) {} diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/api/DocumentUploadedByDto.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/api/DocumentUploadedByDto.java new file mode 100644 index 0000000000000000000000000000000000000000..e07fa1ccb4672c0098e5bb29edf36d25b040f567 --- /dev/null +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/api/DocumentUploadedByDto.java @@ -0,0 +1,14 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.officialmedicalservice.document.api; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "DocumentUploadedBy") +public enum DocumentUploadedByDto { + INTERN, + EXTERN, +} diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/persistence/entity/DocumentUploadedBy.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/persistence/entity/DocumentUploadedBy.java new file mode 100644 index 0000000000000000000000000000000000000000..99fab2dcc502a65c4dce3dac5433ef451a44fc8f --- /dev/null +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/persistence/entity/DocumentUploadedBy.java @@ -0,0 +1,11 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.officialmedicalservice.document.persistence.entity; + +public enum DocumentUploadedBy { + INTERN, + EXTERN, +} diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/persistence/entity/OmsDocument.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/persistence/entity/OmsDocument.java index 7cf239e4df0b35e85b6119b2a5d4f97ca383d3fa..f3a1a9ad6ed21f6c394e7914f102ae4233c41aef 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/persistence/entity/OmsDocument.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/document/persistence/entity/OmsDocument.java @@ -86,6 +86,11 @@ public class OmsDocument extends GloballyUniqueEntityBase { @DataSensitivity(SensitivityLevel.PSEUDONYMIZED) private boolean uploadInCitizenPortal; + @Column + @JdbcType(PostgreSQLEnumJdbcType.class) + @DataSensitivity(SensitivityLevel.PSEUDONYMIZED) + private DocumentUploadedBy uploadedBy; + @Column @DataSensitivity(SensitivityLevel.PSEUDONYMIZED) private String reasonForRejection; @@ -174,6 +179,14 @@ public class OmsDocument extends GloballyUniqueEntityBase { this.uploadInCitizenPortal = uploadInCitizenPortal; } + public DocumentUploadedBy getUploadedBy() { + return uploadedBy; + } + + public void setUploadedBy(DocumentUploadedBy uploadedBy) { + this.uploadedBy = uploadedBy; + } + public String getReasonForRejection() { return reasonForRejection; } diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/eventmetadata/OfficialMedicalServiceEventMetadataService.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/eventmetadata/OfficialMedicalServiceEventMetadataService.java index 0c4d8c13f6d7c04cf2496adfd5628b10b9ff88f1..f6c6959aea6d0226d7fd38d1fd458cc3c5648d70 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/eventmetadata/OfficialMedicalServiceEventMetadataService.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/eventmetadata/OfficialMedicalServiceEventMetadataService.java @@ -5,6 +5,9 @@ package de.eshg.officialmedicalservice.eventmetadata; +import static de.eshg.lib.appointmentblock.persistence.AppointmentType.OFFICIAL_MEDICAL_SERVICE_LONG; +import static de.eshg.lib.appointmentblock.persistence.AppointmentType.OFFICIAL_MEDICAL_SERVICE_SHORT; + import de.eshg.calendar.lib.EventMetadataService; import de.eshg.calendar.lib.api.EventWithMetaData; import de.eshg.lib.appointmentblock.AppointmentBlockSlotUtil; @@ -19,6 +22,8 @@ import org.springframework.stereotype.Service; @Service public class OfficialMedicalServiceEventMetadataService implements EventMetadataService { + private static final List<AppointmentType> supportedAppointmentTypes = + List.of(OFFICIAL_MEDICAL_SERVICE_SHORT, OFFICIAL_MEDICAL_SERVICE_LONG); private final AppointmentBlockRepository appointmentBlockRepository; private final AppointmentBlockSlotUtil appointmentBlockSlotUtil; @@ -46,7 +51,7 @@ public class OfficialMedicalServiceEventMetadataService implements EventMetadata AppointmentType type = appointmentBlockData.appointmentBlock().getAppointmentBlockGroup().getType(); - if (type != AppointmentType.OFFICIAL_MEDICAL_SERVICE) { + if (!supportedAppointmentTypes.contains(type)) { throw new IllegalArgumentException("Unexpected appointment block type: " + type); } diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/notification/MailClient.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/notification/MailClient.java index 77ff8f8f2e5d065caf202afd3cc2eb50903961d1..de5450b8aa6f8d3f7d1b0281d0ea217663305c67 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/notification/MailClient.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/notification/MailClient.java @@ -6,6 +6,7 @@ package de.eshg.officialmedicalservice.notification; import de.eshg.base.mail.MailApi; +import de.eshg.base.mail.MailType; import de.eshg.base.mail.SendEmailRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,7 +25,8 @@ public class MailClient { void sendMail(String to, String from, String subject, String text) { log.info("Sending E-Mail notification"); - SendEmailRequest sendEmailRequest = new SendEmailRequest(to, from, subject, text); + SendEmailRequest sendEmailRequest = + new SendEmailRequest(to, from, subject, text, MailType.PLAIN_TEXT); mailApi.sendEmail(sendEmailRequest); log.info("E-Mail notification sent"); diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/person/PersonClient.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/person/PersonClient.java index c0f7b352f7fae81fcbf66a0ab34e822c558c8019..6d69d9269979a0ba1e4b78a6c2ff486fe0ba6f26 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/person/PersonClient.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/person/PersonClient.java @@ -38,6 +38,11 @@ public class PersonClient { return doAndForwardErrorCodes(() -> personApi.addPersonFileState(request)); } + public AddPersonFileStateResponse addPersonFromExternalSource( + ExternalAddPersonFileStateRequest request) { + return doAndForwardErrorCodes(() -> personApi.addPersonFromExternalSource(request)); + } + public GetPersonFileStateResponse getPersonFileState(UUID id) { return doAndForwardErrorCodes(() -> personApi.getPersonFileState(id)); } diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/person/PersonMapper.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/person/PersonMapper.java index d39b6e7fddea1d0001675fae1b6c7169be7e421f..4b71c28d3523781cdd41d03b4fa886455acb0d76 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/person/PersonMapper.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/person/PersonMapper.java @@ -7,6 +7,7 @@ package de.eshg.officialmedicalservice.person; import de.eshg.base.centralfile.api.DataOriginDto; import de.eshg.base.centralfile.api.person.AddPersonFileStateRequest; +import de.eshg.base.centralfile.api.person.ExternalAddPersonFileStateRequest; import de.eshg.base.centralfile.api.person.GetPersonFileStateResponse; import de.eshg.base.centralfile.api.person.PersonDetailsDto; import de.eshg.base.centralfile.api.person.UpdatePersonRequest; @@ -49,6 +50,14 @@ public class PersonMapper { mapToPersonDetailsDto(affectedPersonDto), DataOriginDto.MANUAL); } + public static ExternalAddPersonFileStateRequest mapToExternalAddPersonFileStateRequest( + AffectedPersonDto affectedPersonDto) { + if (affectedPersonDto == null) { + return null; + } + return new ExternalAddPersonFileStateRequest(mapToPersonDetailsDto(affectedPersonDto)); + } + public static UpdatePersonRequest mapToUpdatePersonRequest(AffectedPersonDto affectedPersonDto) { if (affectedPersonDto == null) { return null; diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/EmployeeOmsProcedureService.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/EmployeeOmsProcedureService.java index a304053128191eb3f3d281b1368548faf24e2ea7..f68daa866a6e88eaa35dc236736fafb13b179b91 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/EmployeeOmsProcedureService.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/EmployeeOmsProcedureService.java @@ -43,6 +43,9 @@ import de.eshg.officialmedicalservice.appointment.persistence.entity.OmsAppointm import de.eshg.officialmedicalservice.concern.ConcernMapper; import de.eshg.officialmedicalservice.document.OmsDocumentMapper; import de.eshg.officialmedicalservice.document.api.GetDocumentsResponse; +import de.eshg.officialmedicalservice.document.persistence.entity.OmsDocument; +import de.eshg.officialmedicalservice.document.persistence.entity.OmsDocumentRepository; +import de.eshg.officialmedicalservice.document.persistence.entity.OmsDocumentStatus; import de.eshg.officialmedicalservice.facility.FacilityClient; import de.eshg.officialmedicalservice.facility.FacilityMapper; import de.eshg.officialmedicalservice.notification.NotificationService; @@ -137,6 +140,7 @@ public class EmployeeOmsProcedureService { SecurityContextHolder.getContextHolderStrategy(); private final ModuleClientAuthenticator moduleClientAuthenticator; private final CitizenAccessCodeUserClient citizenAccessCodeUserClient; + private final OmsDocumentRepository omsDocumentRepository; public EmployeeOmsProcedureService( OmsProcedureRepository omsProcedureRepository, @@ -153,7 +157,8 @@ public class EmployeeOmsProcedureService { OmsDocumentMapper omsDocumentMapper, NotificationService notificationService, ModuleClientAuthenticator moduleClientAuthenticator, - CitizenAccessCodeUserClient citizenAccessCodeUserClient) { + CitizenAccessCodeUserClient citizenAccessCodeUserClient, + OmsDocumentRepository omsDocumentRepository) { this.omsProcedureRepository = omsProcedureRepository; this.omsProcedureOverviewMapper = omsProcedureOverviewMapper; this.omsAppointmentMapper = omsAppointmentMapper; @@ -169,6 +174,7 @@ public class EmployeeOmsProcedureService { this.notificationService = notificationService; this.moduleClientAuthenticator = moduleClientAuthenticator; this.citizenAccessCodeUserClient = citizenAccessCodeUserClient; + this.omsDocumentRepository = omsDocumentRepository; } @Transactional @@ -179,10 +185,18 @@ public class EmployeeOmsProcedureService { OmsProcedure procedure = omsProcedureOverviewMapper.toDomainType( - request, CurrentUserHelper.getCurrentUserId(), affectedPersonBaseResponse, null); + CurrentUserHelper.getCurrentUserId(), affectedPersonBaseResponse, null); omsProcedureRepository.save(procedure); + OmsDocument document = new OmsDocument(); + document.setDocumentStatus(OmsDocumentStatus.MISSING); + document.setDocumentTypeDe("Auftragsschreiben"); + document.setUploadInCitizenPortal(false); + document.setMandatoryDocument(true); + document.setOmsProcedure(procedure); + omsDocumentRepository.save(document); + return procedure.getExternalId(); } @@ -506,7 +520,10 @@ public class EmployeeOmsProcedureService { String accessCode = citizenAccessCodeUser.accessCode(); omsProcedure.setCitizenUserId(citizenAccessCodeUser.userId()); - omsProcedure.updateProcedureStatus(ProcedureStatus.OPEN, clock, auditLogger); + + Instant now = clock.instant(); + omsProcedure.setStartedAt(now); + omsProcedure.updateProcedureStatus(ProcedureStatus.OPEN, now, auditLogger); NotificationService.NotificationSummary notificationSummary = notificationService.notifyNewCitizenUser( diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/OmsProcedureOverviewMapper.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/OmsProcedureOverviewMapper.java index f371150707a9652cf28daaa580834394a69f896f..17bc924e38277e8ec3f61641cb96c337db80810a 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/OmsProcedureOverviewMapper.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/OmsProcedureOverviewMapper.java @@ -19,7 +19,6 @@ import de.eshg.lib.procedure.mapping.ProcedureMapper; import de.eshg.officialmedicalservice.concern.ConcernMapper; import de.eshg.officialmedicalservice.procedure.api.EmployeeOmsProcedureOverviewDto; import de.eshg.officialmedicalservice.procedure.api.MedicalOpinionStatusDto; -import de.eshg.officialmedicalservice.procedure.api.PostEmployeeOmsProcedureRequest; import de.eshg.officialmedicalservice.procedure.persistence.entity.MedicalOpinionStatus; import de.eshg.officialmedicalservice.procedure.persistence.entity.OmsProcedure; import de.eshg.officialmedicalservice.procedure.persistence.entity.OmsTask; @@ -42,10 +41,7 @@ public class OmsProcedureOverviewMapper { } public OmsProcedure toDomainType( - PostEmployeeOmsProcedureRequest request, - UUID currentUserId, - AddPersonFileStateResponse affectedPersonBaseResponse, - UUID physicianId) { + UUID currentUserId, AddPersonFileStateResponse affectedPersonBaseResponse, UUID physicianId) { OmsProcedure procedure = new OmsProcedure(); diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/ProgressEntryService.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/ProgressEntryService.java index 46851359404ec6fa38a79287c1d247716dd136c9..b0b3a450cc2692ccc6dc18e2c8042453e196c743 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/ProgressEntryService.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/ProgressEntryService.java @@ -34,22 +34,22 @@ public class ProgressEntryService { } public void createProgressEntryForUpdateAffectedPerson( - OmsProcedure procedure, UUID previousFileStateId) { + OmsProcedure procedure, UUID previousPersonFileStateId) { SystemProgressEntry progressEntry = SystemProgressEntryFactory.createSystemProgressEntry( OmsProgressEntryType.UPDATE_AFFECTED_PERSON.name(), TriggerType.SYSTEM_AUTOMATIC); progressEntry.setProcedureId(procedure.getId()); - progressEntry.setPreviousFileStateId(previousFileStateId); + progressEntry.setPreviousPersonFileStateId(previousPersonFileStateId); procedure.addProgressEntry(progressEntry); } public void createProgressEntryForSyncAffectedPerson( - OmsProcedure procedure, UUID previousFileStateId) { + OmsProcedure procedure, UUID previousPersonFileStateId) { SystemProgressEntry progressEntry = SystemProgressEntryFactory.createSystemProgressEntry( OmsProgressEntryType.SYNC_AFFECTED_PERSON.name(), TriggerType.SYSTEM_AUTOMATIC); progressEntry.setProcedureId(procedure.getId()); - progressEntry.setPreviousFileStateId(previousFileStateId); + progressEntry.setPreviousPersonFileStateId(previousPersonFileStateId); procedure.addProgressEntry(progressEntry); } diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/api/ConcernConfigDto.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/api/ConcernConfigDto.java index 724a280c0f850f7b60ed2169ea05c6ff002b6736..deae978af5adff01ec0776fb8c263d78d2ef6757 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/api/ConcernConfigDto.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/api/ConcernConfigDto.java @@ -5,6 +5,7 @@ package de.eshg.officialmedicalservice.procedure.api; +import de.eshg.lib.appointmentblock.api.AppointmentTypeDto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -12,7 +13,7 @@ import jakarta.validation.constraints.NotNull; @Schema(name = "ConcernConfig") public record ConcernConfigDto( @NotBlank String nameDe, // reason_de - @NotBlank String nameEn, - @NotBlank String descriptionDe, - @NotBlank String descriptionEn, - @NotNull boolean highPriority) {} + String nameEn, + @NotNull boolean highPriority, + AppointmentTypeDto appointmentType, + @NotNull boolean visibleInOnlinePortal) {} diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/api/ConcernDto.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/api/ConcernDto.java index a29e664976cd115454b554163177e1d6e3a43ea3..5db05a8cf44aca8fb963938430419ae2b8921787 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/api/ConcernDto.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/api/ConcernDto.java @@ -5,6 +5,7 @@ package de.eshg.officialmedicalservice.procedure.api; +import de.eshg.lib.appointmentblock.api.AppointmentTypeDto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -13,9 +14,9 @@ import jakarta.validation.constraints.NotNull; public record ConcernDto( @NotNull long version, @NotBlank String nameDe, // reason_de - @NotBlank String nameEn, - @NotBlank String descriptionDe, - @NotBlank String descriptionEn, + String nameEn, @NotNull boolean highPriority, @NotBlank String categoryNameDe, - @NotBlank String categoryNameEn) {} + @NotBlank String categoryNameEn, + AppointmentTypeDto appointmentType, + @NotNull boolean visibleInOnlinePortal) {} diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/api/PostCitizenProcedureRequest.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/api/PostCitizenProcedureRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..596974502473d9cfc9a0e55962ff6d5ad67278b9 --- /dev/null +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/api/PostCitizenProcedureRequest.java @@ -0,0 +1,17 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.officialmedicalservice.procedure.api; + +import de.eshg.officialmedicalservice.appointment.api.PostOmsAppointmentRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +@Schema(name = "PostCitizenProcedureRequest") +public record PostCitizenProcedureRequest( + @NotNull @Valid ConcernDto concern, + @NotNull @Valid PostOmsAppointmentRequest appointment, + @NotNull @Valid AffectedPersonDto affectedPerson) {} diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/persistence/entity/Concern.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/persistence/entity/Concern.java index d725d2976950fd1c1d49d7606f0f7c15e6ae9b5f..353c95038063eb67dd110f6a282528f48dbb51ae 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/persistence/entity/Concern.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/persistence/entity/Concern.java @@ -6,11 +6,14 @@ package de.eshg.officialmedicalservice.procedure.persistence.entity; import de.eshg.domain.model.BaseEntity; +import de.eshg.lib.appointmentblock.persistence.AppointmentType; import de.eshg.lib.common.DataSensitivity; import de.eshg.lib.common.SensitivityLevel; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.validation.constraints.NotNull; +import org.hibernate.annotations.JdbcType; +import org.hibernate.dialect.PostgreSQLEnumJdbcType; @Entity public class Concern extends BaseEntity { @@ -20,35 +23,34 @@ public class Concern extends BaseEntity { @DataSensitivity(SensitivityLevel.PSEUDONYMIZED) private String nameDe; - @Column(nullable = false) - @NotNull + @Column @DataSensitivity(SensitivityLevel.PSEUDONYMIZED) private String nameEn; @Column(nullable = false) @NotNull @DataSensitivity(SensitivityLevel.PSEUDONYMIZED) - private String descriptionDe; + private boolean highPriority; @Column(nullable = false) @NotNull @DataSensitivity(SensitivityLevel.PSEUDONYMIZED) - private String descriptionEn; + private String categoryNameDe; @Column(nullable = false) @NotNull @DataSensitivity(SensitivityLevel.PSEUDONYMIZED) - private boolean highPriority; + private String categoryNameEn; - @Column(nullable = false) - @NotNull + @Column + @JdbcType(PostgreSQLEnumJdbcType.class) @DataSensitivity(SensitivityLevel.PSEUDONYMIZED) - private String categoryNameDe; + private AppointmentType appointmentType; @Column(nullable = false) @NotNull @DataSensitivity(SensitivityLevel.PSEUDONYMIZED) - private String categoryNameEn; + private boolean visibleInOnlinePortal; public @NotNull String getNameDe() { return nameDe; @@ -58,30 +60,14 @@ public class Concern extends BaseEntity { this.nameDe = nameDe; } - public @NotNull String getNameEn() { + public String getNameEn() { return nameEn; } - public void setNameEn(@NotNull String nameEn) { + public void setNameEn(String nameEn) { this.nameEn = nameEn; } - public @NotNull String getDescriptionDe() { - return descriptionDe; - } - - public void setDescriptionDe(@NotNull String descriptionDe) { - this.descriptionDe = descriptionDe; - } - - public @NotNull String getDescriptionEn() { - return descriptionEn; - } - - public void setDescriptionEn(@NotNull String descriptionEn) { - this.descriptionEn = descriptionEn; - } - @NotNull public boolean isHighPriority() { return highPriority; @@ -106,4 +92,20 @@ public class Concern extends BaseEntity { public void setCategoryNameEn(@NotNull String categoryNameEn) { this.categoryNameEn = categoryNameEn; } + + public AppointmentType getAppointmentType() { + return appointmentType; + } + + public void setAppointmentType(AppointmentType appointmentType) { + this.appointmentType = appointmentType; + } + + public boolean isVisibleInOnlinePortal() { + return visibleInOnlinePortal; + } + + public void setVisibleInOnlinePortal(boolean visibleInOnlinePortal) { + this.visibleInOnlinePortal = visibleInOnlinePortal; + } } diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/persistence/entity/OmsProcedure.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/persistence/entity/OmsProcedure.java index 400d3d88329b53a3d87b059bab0395446fa794e0..3211be6fca086898b1c1baa87c099d2d98e1630d 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/persistence/entity/OmsProcedure.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/procedure/persistence/entity/OmsProcedure.java @@ -26,6 +26,7 @@ import jakarta.persistence.OneToOne; import jakarta.persistence.OrderBy; import jakarta.persistence.Transient; import jakarta.validation.constraints.NotNull; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -58,7 +59,7 @@ public class OmsProcedure extends Procedure<OmsProcedure, OmsTask, Person, Facil mappedBy = OmsDocument_.OMS_PROCEDURE, cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true) - @OrderBy + @OrderBy(OmsDocument_.DOCUMENT_TYPE_DE) @BatchSize(size = 100) @DataSensitivity(SensitivityLevel.PSEUDONYMIZED) private final List<OmsDocument> documents = new ArrayList<>(); @@ -85,6 +86,10 @@ public class OmsProcedure extends Procedure<OmsProcedure, OmsTask, Person, Facil @Column private UUID citizenUserId; + @DataSensitivity(SensitivityLevel.PUBLIC) + @Column + private Instant startedAt; + public Person findAffectedPerson() { if (getRelatedPersons().isEmpty()) { return null; @@ -153,4 +158,12 @@ public class OmsProcedure extends Procedure<OmsProcedure, OmsTask, Person, Facil public void setCitizenUserId(UUID citizenUserId) { this.citizenUserId = citizenUserId; } + + public Instant getStartedAt() { + return startedAt; + } + + public void setStartedAt(Instant startedAt) { + this.startedAt = startedAt; + } } diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/OmsTestHelperController.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/OmsTestHelperController.java index 88ecbc7e3861e005f7056edc88db4f71ce0f9f5c..7865e0e6b2e73a7238255ca762dcb1c5a35dce68 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/OmsTestHelperController.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/OmsTestHelperController.java @@ -11,6 +11,7 @@ import de.eshg.officialmedicalservice.testhelper.api.PostPopulateAdministrativeR import de.eshg.officialmedicalservice.testhelper.api.PostPopulateProcedureRequest; import de.eshg.officialmedicalservice.testhelper.api.PostPopulateProcedureResponse; import de.eshg.testhelper.ConditionalOnTestHelperEnabled; +import de.eshg.testhelper.DefaultTestHelperService; import de.eshg.testhelper.TestHelperApi; import de.eshg.testhelper.TestHelperController; import de.eshg.testhelper.environment.EnvironmentConfig; @@ -32,12 +33,12 @@ public class OmsTestHelperController extends TestHelperController private final AuditLogTestHelperService auditLogTestHelperService; public OmsTestHelperController( - OmsTestHelperService omsTestHelperService, + DefaultTestHelperService testHelperService, TestPopulateProcedureService testPopulateProcedureService, TestPopulateAdministrativeService testPopulateAdministrativeService, AuditLogTestHelperService auditLogTestHelperService, EnvironmentConfig environmentConfig) { - super(omsTestHelperService, environmentConfig); + super(testHelperService, environmentConfig); this.testPopulateProcedureService = testPopulateProcedureService; this.testPopulateAdministrativeService = testPopulateAdministrativeService; this.auditLogTestHelperService = auditLogTestHelperService; diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/OmsTestHelperResetAction.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/OmsTestHelperResetAction.java new file mode 100644 index 0000000000000000000000000000000000000000..435509162bc50f44cc77f0c31156458364b02279 --- /dev/null +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/OmsTestHelperResetAction.java @@ -0,0 +1,29 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.officialmedicalservice.testhelper; + +import de.eshg.lib.appointmentblock.persistence.CreateAppointmentTypeTask; +import de.eshg.testhelper.ConditionalOnTestHelperEnabled; +import de.eshg.testhelper.TestHelperServiceResetAction; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@ConditionalOnTestHelperEnabled +@Component +@Order(50) +public class OmsTestHelperResetAction implements TestHelperServiceResetAction { + + private final CreateAppointmentTypeTask createAppointmentTypeTask; + + public OmsTestHelperResetAction(CreateAppointmentTypeTask createAppointmentTypeTask) { + this.createAppointmentTypeTask = createAppointmentTypeTask; + } + + @Override + public void reset() { + createAppointmentTypeTask.createAppointmentTypes(); + } +} diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/OmsTestHelperService.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/OmsTestHelperService.java deleted file mode 100644 index f0110b566806f22b0739aaf7836e9443a7780190..0000000000000000000000000000000000000000 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/OmsTestHelperService.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: Apache-2.0 - */ - -package de.eshg.officialmedicalservice.testhelper; - -import de.eshg.lib.appointmentblock.persistence.CreateAppointmentTypeTask; -import de.eshg.testhelper.*; -import de.eshg.testhelper.environment.EnvironmentConfig; -import de.eshg.testhelper.interception.TestRequestInterceptor; -import de.eshg.testhelper.population.BasePopulator; -import java.time.Clock; -import java.time.Instant; -import java.util.List; -import org.springframework.stereotype.Service; - -@ConditionalOnTestHelperEnabled -@Service -public class OmsTestHelperService extends DefaultTestHelperService { - - private final CreateAppointmentTypeTask createAppointmentTypeTask; - - public OmsTestHelperService( - DatabaseResetHelper databaseResetHelper, - TestRequestInterceptor testRequestInterceptor, - Clock clock, - List<BasePopulator<?>> populators, - List<ResettableProperties> resettableProperties, - CreateAppointmentTypeTask createAppointmentTypeTask, - EnvironmentConfig environmentConfig) { - super( - databaseResetHelper, - testRequestInterceptor, - clock, - populators, - resettableProperties, - environmentConfig); - this.createAppointmentTypeTask = createAppointmentTypeTask; - } - - @Override - public Instant reset() throws Exception { - Instant newInstant = super.reset(); - createAppointmentTypeTask.createAppointmentTypes(); - return newInstant; - } -} diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/TestPopulateAdministrativeService.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/TestPopulateAdministrativeService.java index 166af45632dd45bd9245688aaec9c55e77269c2b..ae4ff042bb973580f02126864c43b552bf00dd09 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/TestPopulateAdministrativeService.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/TestPopulateAdministrativeService.java @@ -32,7 +32,8 @@ import org.springframework.transaction.annotation.Transactional; @Service public class TestPopulateAdministrativeService { - public static final String OMS_NOW_KEY = "Amtsärtzlicher Dienst_heute_09_Uhr"; + public static final String OMS_NOW_SHORT_KEY = "Amtsärztlicher Dienst_heute_kurz_09_Uhr"; + public static final String OMS_NOW_LONG_KEY = "Amtsärztlicher Dienst_heute_lang_09_Uhr"; private final AppointmentBlockService appointmentBlockService; private final Clock clock; @@ -83,13 +84,13 @@ public class TestPopulateAdministrativeService { .toInstant(); // 9th March to test months change in appointment picker - Instant endBlock_omsNow = startBlock_omsNow.plus(Duration.ofDays(18).plusHours(3L)); + Instant endBlock_omsNow = startBlock_omsNow.plus(Duration.ofDays(18).plusHours(4L)); - UUID appointmentBlockGroup_omsNow = + UUID appointmentBlockGroupShort_omsNow = appointmentBlockService .createDailyAppointmentBlocksForGroup( new CreateDailyAppointmentBlockGroupRequest( - AppointmentTypeDto.OFFICIAL_MEDICAL_SERVICE, + AppointmentTypeDto.OFFICIAL_MEDICAL_SERVICE_SHORT, 2, List.of( new CreateDailyAppointmentBlockDto( @@ -104,8 +105,25 @@ public class TestPopulateAdministrativeService { List.of())) .id(); + UUID appointmentBlockGroupLong_omsNow = + appointmentBlockService + .createDailyAppointmentBlocksForGroup( + new CreateDailyAppointmentBlockGroupRequest( + AppointmentTypeDto.OFFICIAL_MEDICAL_SERVICE_LONG, + 2, + List.of( + new CreateDailyAppointmentBlockDto( + startBlock_omsNow, + endBlock_omsNow, + List.of(DayOfWeekDto.THURSDAY, DayOfWeekDto.FRIDAY))), + List.of(physician), + List.of(), + List.of())) + .id(); + Map<String, UUID> appointmentBlockGroups = new LinkedHashMap<>(); - appointmentBlockGroups.put(OMS_NOW_KEY, appointmentBlockGroup_omsNow); + appointmentBlockGroups.put(OMS_NOW_SHORT_KEY, appointmentBlockGroupShort_omsNow); + appointmentBlockGroups.put(OMS_NOW_LONG_KEY, appointmentBlockGroupLong_omsNow); return appointmentBlockGroups; } } diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/TestPopulateProcedureService.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/TestPopulateProcedureService.java index aae239a79e063a9856f630bf39c86a4584dd2a68..aeba777b741f410c18c4aa473fed1950e8398055 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/TestPopulateProcedureService.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/TestPopulateProcedureService.java @@ -9,6 +9,7 @@ import static de.eshg.lib.procedure.model.ProcedureStatusDto.CLOSED; import static de.eshg.lib.procedure.model.ProcedureStatusDto.OPEN; import de.eshg.officialmedicalservice.appointment.OmsAppointmentService; +import de.eshg.officialmedicalservice.citizenpublic.CitizenProcedureService; import de.eshg.officialmedicalservice.concern.ConcernMapper; import de.eshg.officialmedicalservice.concern.ConcernService; import de.eshg.officialmedicalservice.document.OmsDocumentService; @@ -22,8 +23,12 @@ import de.eshg.officialmedicalservice.procedure.api.ConcernDto; import de.eshg.officialmedicalservice.procedure.api.PatchConcernRequest; import de.eshg.officialmedicalservice.procedure.api.PatchEmployeeOmsProcedureEmailNotificationsRequest; import de.eshg.officialmedicalservice.procedure.api.PatchEmployeeOmsProcedurePhysicianRequest; +import de.eshg.officialmedicalservice.procedure.api.PostCitizenProcedureRequest; import de.eshg.officialmedicalservice.testhelper.api.AppointmentPopulationDto; +import de.eshg.officialmedicalservice.testhelper.api.ConcernTestDataConfig; import de.eshg.officialmedicalservice.testhelper.api.DocumentPopulationDto; +import de.eshg.officialmedicalservice.testhelper.api.FileTestDataConfig; +import de.eshg.officialmedicalservice.testhelper.api.PostPopulateCitizenProcedureRequest; import de.eshg.officialmedicalservice.testhelper.api.PostPopulateProcedureRequest; import de.eshg.officialmedicalservice.testhelper.api.PostPopulateProcedureResponse; import de.eshg.officialmedicalservice.waitingroom.WaitingRoomService; @@ -38,12 +43,14 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -111,6 +118,7 @@ API Response public class TestPopulateProcedureService { private final EmployeeOmsProcedureService employeeOmsProcedureService; + private final CitizenProcedureService citizenProcedureService; private final ConcernService concernService; private final OmsAppointmentService appointmentService; private final PopulateWithAccessTokenHelper populateWithAccessTokenHelper; @@ -120,6 +128,7 @@ public class TestPopulateProcedureService { public TestPopulateProcedureService( EmployeeOmsProcedureService employeeOmsProcedureService, + CitizenProcedureService citizenProcedureService, ConcernService concernService, OmsAppointmentService appointmentService, PopulateWithAccessTokenHelper populateWithAccessTokenHelper, @@ -127,6 +136,7 @@ public class TestPopulateProcedureService { OmsDocumentRepository omsDocumentRepository, WaitingRoomService waitingRoomService) { this.employeeOmsProcedureService = employeeOmsProcedureService; + this.citizenProcedureService = citizenProcedureService; this.concernService = concernService; this.appointmentService = appointmentService; this.populateWithAccessTokenHelper = populateWithAccessTokenHelper; @@ -146,8 +156,12 @@ public class TestPopulateProcedureService { Map<String, UUID> documentMap = new HashMap<>(); // 1. create procedure - procedureId = - employeeOmsProcedureService.createEmployeeProcedure((request.procedureData())); + if (request.procedureData() != null) { + procedureId = + employeeOmsProcedureService.createEmployeeProcedure((request.procedureData())); + } else { + procedureId = addCitizenProcedure(request.procedureDataCitizen()); + } // 2. Deactivate email notifications if (request.sendEmailNotifications() != null) { @@ -164,22 +178,7 @@ public class TestPopulateProcedureService { // 4. add concern if (request.concern() != null) { - ConcernDto concern = - concernService.getConcerns().categories().stream() - .flatMap( - category -> - category.concerns().stream() - .filter( - concernDto -> - concernDto.nameDe().equals(request.concern().getNameDe())) - .map( - concernConfigDto -> - ConcernMapper.mapConcernConfigToConcernDto( - concernConfigDto, category, 0)) - .findFirst() - .stream()) - .findFirst() - .orElseThrow(); + ConcernDto concern = loadConcern(request.concern()); employeeOmsProcedureService.updateOmsProcedureConcern( procedureId, new PatchConcernRequest(concern)); @@ -196,30 +195,29 @@ public class TestPopulateProcedureService { employeeOmsProcedureService.acceptDraftProcedure(procedureId); } - // 7. add appointments - appointmentMap = addAppointments(procedureId, request.appointments()); - - // 8. cancel appointments - cancelAppointments(request.cancelledAppointments(), appointmentMap); + // 7. add (and cancel and close) appointments + appointmentMap = + addAppointments( + procedureId, + request.appointments(), + request.cancelledAppointments(), + request.closedAppointments()); - // 9. close appointments - closeAppointments(request.closedAppointments(), appointmentMap); - - // 10. add documents + // 8. add documents documentMap = addDocuments(procedureId, request.documents()); - // 11. update medical opinion status + // 9. update medical opinion status if (request.medicalOpinionStatus() != null) { employeeOmsProcedureService.updateMedicalOpinionStatus( procedureId, request.medicalOpinionStatus()); } - // 12. update waiting room + // 10. update waiting room if (request.waitingRoom() != null) { waitingRoomService.updateWaitingRoom(procedureId, request.waitingRoom()); } - // 13. close procedure + // 11. close procedure if (Objects.equals(CLOSED, request.targetState())) { employeeOmsProcedureService.closeOpenProcedure(procedureId); } @@ -229,47 +227,44 @@ public class TestPopulateProcedureService { }); } + private UUID addCitizenProcedure(PostPopulateCitizenProcedureRequest procedureDataCitizen) { + PostCitizenProcedureRequest request = + new PostCitizenProcedureRequest( + loadConcern(procedureDataCitizen.concern()), + procedureDataCitizen.appointment().request(), + procedureDataCitizen.affectedPerson()); + return citizenProcedureService.createCitizenProcedure( + request, loadFiles(procedureDataCitizen.files())); + } + private Map<String, UUID> addAppointments( - UUID procedureId, List<AppointmentPopulationDto> appointmentPopulations) { + UUID procedureId, + List<AppointmentPopulationDto> appointmentPopulations, + List<String> canceledAppointments, + List<String> closedAppointments) { Map<String, UUID> appointmentMap = new LinkedHashMap<>(); + Set<String> canceledAppointmentsSet = + new HashSet<>( + canceledAppointments != null ? canceledAppointments : Collections.emptyList()); + Set<String> closedAppointmentsSet = + new HashSet<>(closedAppointments != null ? closedAppointments : Collections.emptyList()); if (appointmentPopulations != null) { appointmentPopulations.forEach( population -> { UUID appointmentId = appointmentService.addAppointmentEmployee(procedureId, population.request()); appointmentMap.put(population.key(), appointmentId); + if (canceledAppointmentsSet.contains(population.key())) { + appointmentService.cancelAppointmentEmployee(appointmentId); + } + if (closedAppointmentsSet.contains(population.key())) { + appointmentService.closeAppointmentEmployee(appointmentId); + } }); } return appointmentMap; } - private void cancelAppointments( - List<String> cancelledAppointmentList, Map<String, UUID> appointmentMap) { - if (cancelledAppointmentList == null) { - return; - } - cancelledAppointmentList.forEach( - appointment -> { - UUID appointmentId = - Optional.of(appointmentMap.get(appointment)) - .orElseThrow(() -> new RuntimeException("Unknown appointment key")); - appointmentService.cancelAppointmentEmployee(appointmentId); - }); - } - - private void closeAppointments(List<String> appointmentList, Map<String, UUID> appointmentMap) { - if (appointmentList == null) { - return; - } - appointmentList.forEach( - appointment -> { - UUID appointmentId = - Optional.of(appointmentMap.get(appointment)) - .orElseThrow(() -> new RuntimeException("Unknown appointment key")); - appointmentService.closeAppointmentEmployee(appointmentId); - }); - } - private Map<String, UUID> addDocuments( UUID procedureId, List<DocumentPopulationDto> documentPopulation) { Map<String, UUID> documentMap = new LinkedHashMap<>(); @@ -280,27 +275,7 @@ public class TestPopulateProcedureService { String note = null; if (document.targetState() == DocumentStatusDto.ACCEPTED || document.targetState() == DocumentStatusDto.SUBMITTED) { - document - .files() - .forEach( - config -> { - try { - Path filePath = - Paths.get( - getClass() - .getClassLoader() - .getResource("documents/" + config.getName()) - .toURI()); - File file = filePath.toFile(); - - filesToAdd.add( - new OmsDocumentTestHelperFile( - file.getName(), Files.probeContentType(file.toPath()), file)); - } catch (IOException | URISyntaxException e) { - throw new RuntimeException( - "Fehler beim Laden der Testdatei: " + config.getName(), e); - } - }); + filesToAdd = loadFiles(document.files()); if (!document.files().isEmpty()) { note = document.note(); @@ -311,7 +286,7 @@ public class TestPopulateProcedureService { omsDocumentService.addDocumentEmployee( procedureId, document.request(), filesToAdd, note); - // TODO: use document service once citizen portal document service functions exist + // TODO ISSUE-7371: use citizen portal document function from document service if (DocumentStatusDto.SUBMITTED == document.targetState() || DocumentStatusDto.REJECTED == document.targetState()) { omsDocumentRepository @@ -332,4 +307,43 @@ public class TestPopulateProcedureService { } return documentMap; } + + private ConcernDto loadConcern(ConcernTestDataConfig concern) { + return concernService.getConcerns().categories().stream() + .flatMap( + category -> + category.concerns().stream() + .filter(concernDto -> concernDto.nameDe().equals(concern.getNameDe())) + .map( + concernConfigDto -> + ConcernMapper.mapConcernConfigToConcernDto( + concernConfigDto, category, 0)) + .findFirst() + .stream()) + .findFirst() + .orElseThrow(); + } + + private List<MultipartFile> loadFiles(List<FileTestDataConfig> files) { + List<MultipartFile> filesToAdd = new ArrayList<>(); + files.forEach( + config -> { + try { + Path filePath = + Paths.get( + getClass() + .getClassLoader() + .getResource("documents/" + config.getName()) + .toURI()); + File file = filePath.toFile(); + + filesToAdd.add( + new OmsDocumentTestHelperFile( + file.getName(), Files.probeContentType(file.toPath()), file)); + } catch (IOException | URISyntaxException e) { + throw new RuntimeException("Fehler beim Laden der Testdatei: " + config.getName(), e); + } + }); + return filesToAdd; + } } diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/api/ConcernTestDataConfig.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/api/ConcernTestDataConfig.java index be7d552c3b40ca63b47de2a40ce72cee3eac41fa..1999eb92093eddc1b7a7875994a886829da20d3d 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/api/ConcernTestDataConfig.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/api/ConcernTestDataConfig.java @@ -6,11 +6,40 @@ package de.eshg.officialmedicalservice.testhelper.api; public enum ConcernTestDataConfig { - EXAMINATION_ELIGIBILITY("Prüfungsfähigkeit"), - CERTIFICATE_FOR_CALL_OF_DUTY("Dienstfähigkeitsbeurteilungen"), - PRIORITIZATION_OF_CIVIL_SERVANTS("Beamtenpriorisierung"), - EARLY_RETIREMENT("Vorzeitige Pensionierung"), - REVIEW_OF_LONGER_SICK_NOTES("Überprüfung längerer Krankschreibungen"), + DRUG_SCREENING("Alkohol/Drogenscreening"), + REINTEGRATION("Arbeitsversuch / Wiedereingliederung"), + ATTESTATION("Attest (AU ab 1. Krankheitstag)"), + ASSISTANCE("Beihilfe (nach Aktenlage)"), + CERTIFICATE_FOR_CALL_OF_DUTY_FREE("Dienstfähigkeit (gebührenfrei)"), + CERTIFICATE_FOR_CALL_OF_DUTY_PAID("Dienstfähigkeit (gebührenpflichtig)"), + CERTIFICATE_FOR_CALL_OF_DUTY_ADDITION("Dienstfähigkeit / Ergänzung"), + CERTIFICATE_FOR_CALL_OF_DUTY_CONTRADICTION("Dienstfähigkeit / Widerspruch"), + OPERATIONAL_CAPABILITY("Einsatzfähigkeit"), + RECRUITMENT_FREE("Einstellung (gebührenfrei)"), + RECRUITMENT_PAID("Einstellung (gebührenpflichtig)"), + CIVIL_SERVANTS_ON_PROBATION("Einstellung BaP / Verbeamtung auf Probe"), + CIVIL_SERVANTS("Einstellung BaL / Verbeamtung auf Lebenszeit"), + PROBATIONARY_CIVIL_SERVANTS("Einstellung BaW / Verbeamtung auf Widerruf"), + TEMPORARY_CIVIL_SERVANTS("Einstellung BaZ / Verbeamtung auf Zeit"), + RECRUITMENT_CONTRADICTION("Einstellung / Widerspruch"), + RECRUITMENT_FIRE_DEPARTMENT("Einstellung / Werkfeuerwehr"), + HOURLY_DISCOUNT("Stundenermäßigung (Lehrkräfte)"), + ACCIDENT_REPORT_FREE("Unfallbegutachtung (gebührenfrei)"), + ACCIDENT_REPORT_PAID("Unfallbegutachtung (gebührenpflichtig)"), + RESCUE_SERVICES_LAW("§ 27 Hess. Rettungsdienstgesetz"), + PEDIGREE_REPORT("Abstammungsgutachten"), + ADOPTION("Adoption"), + WORK_EARNING_CAPACITY("Arbeits-/ Erwerbsfähigkeit"), + INVESTIGATION_ASSIGNMENT("Gerichtl. Untersuchungsauftrag"), + FOSTER_CHILD("Aufnahme Pflegekind"), + SOCIAL_MEDICINE("Sozialmedizin"), + S_HANDICAPPED("S-Behinderte / § 54 SGB XII"), + PRESELECTION_FIRE_DEPARTMENT("Vorauswahl für Feuerwehr"), + PRESELECTION_FIRE_DEPARTMENT_EYESIGHT("Vorauswahl für Feuerwehr Sehvermögen"), + CONTRADICTION("Widerspruch"), + TAX_OFFICE("Zur Vorlage beim Finanzamt (Privatpersonen)"), + EXAMINATION_ELIGIBILITY("Zur Vorlage beim Prüfungsamt"), + MISCELLANEOUS("Sonstiges"), ; String nameDe; diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/api/PostPopulateCitizenProcedureRequest.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/api/PostPopulateCitizenProcedureRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..9797f3d3a2f7e1c8bd716823cdf7df6d96fb9826 --- /dev/null +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/api/PostPopulateCitizenProcedureRequest.java @@ -0,0 +1,17 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.officialmedicalservice.testhelper.api; + +import de.eshg.officialmedicalservice.procedure.api.AffectedPersonDto; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record PostPopulateCitizenProcedureRequest( + @NotNull ConcernTestDataConfig concern, + @NotNull @Valid AppointmentPopulationDto appointment, + @NotNull @Valid AffectedPersonDto affectedPerson, + @NotNull List<FileTestDataConfig> files) {} diff --git a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/api/PostPopulateProcedureRequest.java b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/api/PostPopulateProcedureRequest.java index fd6cad8c24b2ec45a851918417bb9ef8ba47a2d3..b2d9b4f0ce893859eeefc168d3ecf6240211cd98 100644 --- a/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/api/PostPopulateProcedureRequest.java +++ b/backend/official-medical-service/src/main/java/de/eshg/officialmedicalservice/testhelper/api/PostPopulateProcedureRequest.java @@ -11,12 +11,12 @@ import de.eshg.officialmedicalservice.procedure.api.PostEmployeeOmsProcedureFaci import de.eshg.officialmedicalservice.procedure.api.PostEmployeeOmsProcedureRequest; import de.eshg.officialmedicalservice.waitingroom.api.WaitingRoomDto; import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; import java.util.List; import java.util.UUID; public record PostPopulateProcedureRequest( - @NotNull @Valid PostEmployeeOmsProcedureRequest procedureData, + @Valid PostEmployeeOmsProcedureRequest procedureData, + @Valid PostPopulateCitizenProcedureRequest procedureDataCitizen, @Valid PostEmployeeOmsProcedureFacilityRequest facility, ConcernTestDataConfig concern, UUID physician, diff --git a/backend/official-medical-service/src/main/resources/application-health-department-frankfurt.properties b/backend/official-medical-service/src/main/resources/application-health-department-frankfurt.properties index 9243a0c6b9be61c9496c9650fe47c828e8515b14..7087987be8c2c55ab4929cedb0968907ddcfc2b9 100644 --- a/backend/official-medical-service/src/main/resources/application-health-department-frankfurt.properties +++ b/backend/official-medical-service/src/main/resources/application-health-department-frankfurt.properties @@ -6,7 +6,7 @@ de.eshg.official-medical-service.opening-hours.de[4]=Fr: 08:00 - 11:00 Uhr de.eshg.official-medical-service.opening-hours.de[5]=telefonische Terminvereinbarung de.eshg.official-medical-service.opening-hours.en[0]=Dates by arrangement de.eshg.official-medical-service.opening-hours.en[1]=Mon - Fri: 08:00 - 12:00 -de.eshg.official-medical-service.opening-hours.en[2]=Mon - Thu: 08:00 - 11:00 a.m. and 14:00 - 15:00 +de.eshg.official-medical-service.opening-hours.en[2]=Mon - Thu: 08:00 - 11:00 a.m. and 02:00 - 03:00 p.m. de.eshg.official-medical-service.opening-hours.en[3]=telephone appointment de.eshg.official-medical-service.opening-hours.en[4]=Fri: 08:00 - 11:00 a.m. de.eshg.official-medical-service.opening-hours.en[5]=telephone appointment @@ -30,3 +30,6 @@ de.eshg.official-medical-service.department-info.email=info.amtsaerztlicherdiens de.eshg.official-medical-service.notification.fromAddress=tba@stadt-frankfurt.de de.eshg.official-medical-service.notification.greeting=Ihr TBA-Team der Stadt Frankfurt de.eshg.official-medical-service.notification.templates.path=notifications/ga_frankfurt/de + +de.eshg.official-medical-service.privacy-notice-location=classpath:privacy_documents/privacy_notice.pdf +de.eshg.official-medical-service.privacy-policy-location=classpath:privacy_documents/privacy_policy.pdf diff --git a/backend/official-medical-service/src/main/resources/application.properties b/backend/official-medical-service/src/main/resources/application.properties index 10340045fab7549e144fb4915188a6502b764286..508fce33b0c9947ba95362dcc32b1a44eef93346 100644 --- a/backend/official-medical-service/src/main/resources/application.properties +++ b/backend/official-medical-service/src/main/resources/application.properties @@ -27,12 +27,13 @@ de.eshg.official-medical-service.opening-hours.de[5]=telefonische Terminvereinba de.eshg.official-medical-service.opening-hours.en[0]=Dates by arrangement de.eshg.official-medical-service.opening-hours.en[1]=Mon - Fri: 08:00 - 12:00 -de.eshg.official-medical-service.opening-hours.en[2]=Mon - Thu: 08:00 - 11:00 a.m. and 14:00 - 15:00 +de.eshg.official-medical-service.opening-hours.en[2]=Mon - Thu: 08:00 - 11:00 a.m. and 02:00 - 03:00 p.m. de.eshg.official-medical-service.opening-hours.en[3]=telephone appointment de.eshg.official-medical-service.opening-hours.en[4]=Fri: 08:00 - 11:00 a.m. de.eshg.official-medical-service.opening-hours.en[5]=telephone appointment -de.eshg.lib.appointmentblock.defaultAppointmentTypeConfiguration[OFFICIAL_MEDICAL_SERVICE]=30m +de.eshg.lib.appointmentblock.defaultAppointmentTypeConfiguration[OFFICIAL_MEDICAL_SERVICE_SHORT]=30m +de.eshg.lib.appointmentblock.defaultAppointmentTypeConfiguration[OFFICIAL_MEDICAL_SERVICE_LONG]=120m de.eshg.lib.appointmentblock.createAppointmentBlockForCurrentUser=false eshg.population.default-number-of-entities-to-populate.appointment-block-group=0 @@ -50,3 +51,6 @@ de.eshg.official-medical-service.notification.greeting=Ihr TBA-Team der Stadt Fr de.eshg.official-medical-service.notification.templates.path=notifications/default/de de.eshg.official-medical-service.notification.template.new_citizen_user.subject=Bestätigung de.eshg.official-medical-service.notification.template.new_citizen_user.body=classpath:${de.eshg.official-medical-service.notification.templates.path}/new_citizen_user.txt + +de.eshg.official-medical-service.privacy-notice-location=classpath:privacy_documents/privacy_notice.pdf +de.eshg.official-medical-service.privacy-policy-location=classpath:privacy_documents/privacy_policy.pdf diff --git a/backend/official-medical-service/src/main/resources/concerns/concerns.yaml b/backend/official-medical-service/src/main/resources/concerns/concerns.yaml index 590398c20c6be62486cf645a9969b8c45aba453c..320a74afa83d62a8baea252cf42bd3a8ecef17d8 100644 --- a/backend/official-medical-service/src/main/resources/concerns/concerns.yaml +++ b/backend/official-medical-service/src/main/resources/concerns/concerns.yaml @@ -1,45 +1,215 @@ # Copyright 2025 cronn GmbH # SPDX-License-Identifier: Apache-2.0 -- # Kategorie: Studenten - category_de: Studenten - category_en: students - concerns: - - # Prüfungsfähigkeit - concern_de: Prüfungsfähigkeit - concern_en: Examination eligibility - description_de: Anlass zur Erstellung eines Gutachtens über die Prüfungsfähigkeit eines Studenten. Nur für in Frankfurt Studierende. - description_en: concern for certificates of examination eligibility. Only responsible for students at a Frankfurt university. - high_priority: true -- # Kategorie: Beamte - category_de: Beamte +- # Kategorie: Beamtentum + category_de: Beamtentum category_en: civil servant concerns: - - # Dienstfähigkeitsbeurteilungen - concern_de: Dienstfähigkeitsbeurteilungen - concern_en: Certificate for call of duty - description_de: Anlass zur Erstellung eines Gutachtens über die Dienstfähigkeitsbeurteilungen einer Person, bevor eine Verbeamtung durchgeführt wird. - description_en: concern for certificates of eligibilty to be announced as civil_servant. - high_priority: false - - # Beamtenpriorisierung - concern_de: Beamtenpriorisierung - concern_en: Prioritization of civil servants - description_de: Anlass in Kategorie Beamte mit hoher Priorität - description_en: concern in category civil servant with high priority - high_priority: true + - # Alkohol/Drogenscreening + concern_de: Alkohol/Drogenscreening + concern_en: + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_SHORT + online_portal_visibility: false + - # Arbeitsversuch / Wiedereingliederung + concern_de: Arbeitsversuch / Wiedereingliederung + concern_en: + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_LONG + online_portal_visibility: false + - # Attest (AU ab 1. Krankheitstag) + concern_de: Attest (AU ab 1. Krankheitstag) + concern_en: + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_SHORT + online_portal_visibility: false + - # Beihilfe (nach Aktenlage) + concern_de: Beihilfe (nach Aktenlage) + concern_en: + high_priority: false + appointment_type: + online_portal_visibility: false + - # Dienstfähigkeit (gebührenfrei) + concern_de: Dienstfähigkeit (gebührenfrei) + concern_en: + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_LONG + online_portal_visibility: false + - # Dienstfähigkeit (gebührenpflichtig) + concern_de: Dienstfähigkeit (gebührenpflichtig) + concern_en: + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_LONG + online_portal_visibility: false + - # Dienstfähigkeit / Ergänzung + concern_de: Dienstfähigkeit / Ergänzung + concern_en: + high_priority: false + appointment_type: + online_portal_visibility: false + - # Dienstfähigkeit / Widerspruch + concern_de: Dienstfähigkeit / Widerspruch + concern_en: + high_priority: false + appointment_type: + online_portal_visibility: false + - # Einsatzfähigkeit + concern_de: Einsatzfähigkeit + concern_en: + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_LONG + online_portal_visibility: false + - # Einstellung (gebührenfrei) + concern_de: Einstellung (gebührenfrei) + concern_en: + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_SHORT + online_portal_visibility: false + - # Einstellung (gebührenpflichtig) + concern_de: Einstellung (gebührenpflichtig) + concern_en: + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_SHORT + online_portal_visibility: false + - # Einstellung BaP / Verbeamtung auf Probe + concern_de: Einstellung BaP / Verbeamtung auf Probe + concern_en: Employment / civil servants on probation + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_SHORT + online_portal_visibility: true + - # Einstellung BaL / Verbeamtung auf Lebenszeit + concern_de: Einstellung BaL / Verbeamtung auf Lebenszeit + concern_en: Employment / civil servant + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_SHORT + online_portal_visibility: true + - # Einstellung BaW / Verbeamtung auf Widerruf + concern_de: Einstellung BaW / Verbeamtung auf Widerruf + concern_en: Employment / probationary civil servant + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_SHORT + online_portal_visibility: true + - # Einstellung BaZ / Verbeamtung auf Zeit + concern_de: Einstellung BaZ / Verbeamtung auf Zeit + concern_en: Employment / temporary civil servant + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_SHORT + online_portal_visibility: true + - # Einstellung / Widerspruch + concern_de: Einstellung / Widerspruch + concern_en: + high_priority: false + appointment_type: + online_portal_visibility: false + - # Einstellung / Werkfeuerwehr + concern_de: Einstellung / Werkfeuerwehr + concern_en: + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_SHORT + online_portal_visibility: false + - # Stundenermäßigung (Lehrkräfte) + concern_de: Stundenermäßigung (Lehrkräfte) + concern_en: + high_priority: false + appointment_type: + online_portal_visibility: false + - # Unfallbegutachtung (gebührenfrei) + concern_de: Unfallbegutachtung (gebührenfrei) + concern_en: + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_LONG + online_portal_visibility: false + - # Unfallbegutachtung (gebührenpflichtig) + concern_de: Unfallbegutachtung (gebührenpflichtig) + concern_en: + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_LONG + online_portal_visibility: false - # Kategorie: Sonstiges category_de: Sonstiges category_en: Miscellaneous concerns: - - # Vorzeitige Pensionierung - concern_de: Vorzeitige Pensionierung - concern_en: Early retirement - description_de: Beschreibung Vorzeitige Pensionierung - description_en: Description Early retirement - high_priority: false - - # Überprüfung längerer Krankschreibungen - concern_de: Überprüfung längerer Krankschreibungen - concern_en: Review of longer sick notes - description_de: Beschreibung Überprüfung längerer Krankschreibungen - description_en: Description Review of longer sick notes + - # § 27 Hess. Rettungsdienstgesetz + concern_de: § 27 Hess. Rettungsdienstgesetz + concern_en: + high_priority: false + appointment_type: + online_portal_visibility: false + - # Abstammungsgutachten + concern_de: Abstammungsgutachten + concern_en: + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_SHORT + online_portal_visibility: false + - # Adoption + concern_de: Adoption + concern_en: + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_SHORT + online_portal_visibility: false + - # Arbeits-/ Erwerbsfähigkeit + concern_de: Arbeits-/ Erwerbsfähigkeit + concern_en: + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_LONG + online_portal_visibility: false + - # Gerichtl. Untersuchungsauftrag + concern_de: Gerichtl. Untersuchungsauftrag + concern_en: + high_priority: true + appointment_type: OFFICIAL_MEDICAL_SERVICE_LONG + online_portal_visibility: false + - # Aufnahme Pflegekind + concern_de: Aufnahme Pflegekind + concern_en: + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_SHORT + online_portal_visibility: false + - # Sozialmedizin + concern_de: Sozialmedizin + concern_en: + high_priority: false + appointment_type: + online_portal_visibility: false + - # S-Behinderte / § 54 SGB XII + concern_de: S-Behinderte / § 54 SGB XII + concern_en: + high_priority: false + appointment_type: OFFICIAL_MEDICAL_SERVICE_LONG + online_portal_visibility: false + - # Vorauswahl für Feuerwehr + concern_de: Vorauswahl für Feuerwehr + concern_en: + high_priority: false + appointment_type: + online_portal_visibility: false + - # Vorauswahl für Feuerwehr Sehvermögen + concern_de: Vorauswahl für Feuerwehr Sehvermögen + concern_en: + high_priority: false + appointment_type: + online_portal_visibility: false + - # Widerspruch + concern_de: Widerspruch + concern_en: + high_priority: false + appointment_type: + online_portal_visibility: false + - # Zur Vorlage beim Finanzamt (Privatpersonen) + concern_de: Zur Vorlage beim Finanzamt (Privatpersonen) + concern_en: + high_priority: false + appointment_type: + online_portal_visibility: false + - # Zur Vorlage beim Prüfungsamt + concern_de: Zur Vorlage beim Prüfungsamt + concern_en: + high_priority: true + appointment_type: OFFICIAL_MEDICAL_SERVICE_SHORT + online_portal_visibility: false + - # Sonstiges + concern_de: Sonstiges + concern_en: high_priority: false + appointment_type: + online_portal_visibility: false diff --git a/backend/official-medical-service/src/main/resources/migrations/0002_add_shedlock.xml b/backend/official-medical-service/src/main/resources/migrations/0002_add_shedlock.xml new file mode 100644 index 0000000000000000000000000000000000000000..137f08977efcaddbfea22cbedc067313344b89f8 --- /dev/null +++ b/backend/official-medical-service/src/main/resources/migrations/0002_add_shedlock.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + Copyright 2025 cronn GmbH + SPDX-License-Identifier: Apache-2.0 +--> + +<databaseChangeLog + xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog + http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd"> + <changeSet author="GA-Lotse" id="1729865197316-1"> + <createTable tableName="shedlock"> + <column name="name" type="VARCHAR(64)"> + <constraints nullable="false" primaryKey="true" primaryKeyName="pk_shedlock"/> + </column> + <column name="lock_until" type="TIMESTAMP WITHOUT TIME ZONE"> + <constraints nullable="false"/> + </column> + <column name="locked_at" type="TIMESTAMP WITHOUT TIME ZONE"> + <constraints nullable="false"/> + </column> + <column name="locked_by" type="VARCHAR(255)"> + <constraints nullable="false"/> + </column> + </createTable> + </changeSet> +</databaseChangeLog> diff --git a/backend/official-medical-service/src/main/resources/migrations/changelog.xml b/backend/official-medical-service/src/main/resources/migrations/changelog.xml index 3a9da9b917217e69559abeca7d2ec1e10d1f0b48..eed811911a6e60bbaa2435a1556bacebca5bfb51 100644 --- a/backend/official-medical-service/src/main/resources/migrations/changelog.xml +++ b/backend/official-medical-service/src/main/resources/migrations/changelog.xml @@ -9,5 +9,6 @@ xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.5.xsd"> <include file="migrations/0001_add_cemetery_delete_at.xml"/> + <include file="migrations/0002_add_shedlock.xml"/> </databaseChangeLog> diff --git a/backend/official-medical-service/src/main/resources/privacy_documents/privacy_notice.pdf b/backend/official-medical-service/src/main/resources/privacy_documents/privacy_notice.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e63fc1973f3821e99787f5cc20c908db6aafe96e Binary files /dev/null and b/backend/official-medical-service/src/main/resources/privacy_documents/privacy_notice.pdf differ diff --git a/backend/official-medical-service/src/main/resources/privacy_documents/privacy_policy.pdf b/backend/official-medical-service/src/main/resources/privacy_documents/privacy_policy.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b102d85d0cd0fb89059e88b7e44b9a370493f4a4 Binary files /dev/null and b/backend/official-medical-service/src/main/resources/privacy_documents/privacy_policy.pdf differ diff --git a/backend/opendata/src/main/java/de/eshg/opendata/OpenDataMapper.java b/backend/opendata/src/main/java/de/eshg/opendata/OpenDataMapper.java index f7d5173754300d7865b21ae2595555f0c1aae366..68fee4f5b8082f9a64d58061b18eb16c837343bd 100644 --- a/backend/opendata/src/main/java/de/eshg/opendata/OpenDataMapper.java +++ b/backend/opendata/src/main/java/de/eshg/opendata/OpenDataMapper.java @@ -5,13 +5,10 @@ package de.eshg.opendata; -import de.eshg.file.common.FileType; import de.eshg.opendata.api.ResourceDto; import de.eshg.opendata.api.VersionDto; -import de.eshg.opendata.domain.model.OpenDataFileType; import de.eshg.opendata.domain.model.Resource; import de.eshg.opendata.domain.model.Version; -import de.eshg.rest.service.error.BadRequestException; import java.util.LinkedHashSet; import java.util.List; @@ -49,12 +46,4 @@ class OpenDataMapper { private static List<VersionDto> toInterfaceType(List<Version> versions) { return versions.stream().map(OpenDataMapper::toInterfaceType).toList(); } - - public static OpenDataFileType mapToOpenDataFileType(FileType fileType) { - return switch (fileType) { - case PDF -> OpenDataFileType.PDF; - case CSV -> OpenDataFileType.CSV; - default -> throw new BadRequestException("File type not permitted"); - }; - } } diff --git a/backend/opendata/src/main/java/de/eshg/opendata/OpenDataService.java b/backend/opendata/src/main/java/de/eshg/opendata/OpenDataService.java index ac9fc161c24e404526c340b6950e68c50a8c7b8d..ceef399d95b221fdcdac7221e45b3f5b402b323a 100644 --- a/backend/opendata/src/main/java/de/eshg/opendata/OpenDataService.java +++ b/backend/opendata/src/main/java/de/eshg/opendata/OpenDataService.java @@ -5,6 +5,7 @@ package de.eshg.opendata; +import de.eshg.file.common.CsvValidator; import de.eshg.file.common.FileTypeDetector; import de.eshg.file.common.FileValidator; import de.eshg.file.common.PdfAConformanceValidator; @@ -158,13 +159,17 @@ public class OpenDataService { private OpenDataFileType getFileTypeAndValidateFile(MultipartFile file) { try { FileValidator.validate(file); - OpenDataFileType fileType = - OpenDataMapper.mapToOpenDataFileType(FileTypeDetector.getSupportedFileTypeOrThrow(file)); - - if (fileType.equals(OpenDataFileType.PDF)) { - PdfAConformanceValidator.validate(file.getBytes()); - } - return fileType; + return switch (FileTypeDetector.getSupportedFileTypeOrThrow(file)) { + case PDF -> { + PdfAConformanceValidator.validate(file.getBytes()); + yield OpenDataFileType.PDF; + } + case CSV -> { + CsvValidator.validate(file.getBytes()); + yield OpenDataFileType.CSV; + } + case EML, JPEG, PNG -> throw new BadRequestException("File type not permitted"); + }; } catch (IOException e) { log.error("File header was corrupt", e); throw new BadRequestException("File header was corrupt"); diff --git a/backend/resources/matrix/synapse-db/pg_dump.synapse.local.dev.sql b/backend/resources/matrix/synapse-db/pg_dump.synapse.local.dev.sql index 3ac285ebbbae5989457364e5317439bee6060e83..22023eeca83b2777dca299ec838b30cd83f04698 100644 --- a/backend/resources/matrix/synapse-db/pg_dump.synapse.local.dev.sql +++ b/backend/resources/matrix/synapse-db/pg_dump.synapse.local.dev.sql @@ -393,6 +393,40 @@ CREATE TABLE public.dehydrated_devices ( ALTER TABLE public.dehydrated_devices OWNER TO synapse; +-- +-- Name: delayed_events; Type: TABLE; Schema: public; Owner: synapse +-- + +CREATE TABLE public.delayed_events ( + delay_id text NOT NULL, + user_localpart text NOT NULL, + device_id text, + delay bigint NOT NULL, + send_ts bigint NOT NULL, + room_id text NOT NULL, + event_type text NOT NULL, + state_key text, + origin_server_ts bigint, + content bytea NOT NULL, + is_processed boolean DEFAULT false NOT NULL +); + + +ALTER TABLE public.delayed_events OWNER TO synapse; + +-- +-- Name: delayed_events_stream_pos; Type: TABLE; Schema: public; Owner: synapse +-- + +CREATE TABLE public.delayed_events_stream_pos ( + lock character(1) DEFAULT 'X'::bpchar NOT NULL, + stream_id bigint NOT NULL, + CONSTRAINT delayed_events_stream_pos_lock_check CHECK ((lock = 'X'::bpchar)) +); + + +ALTER TABLE public.delayed_events_stream_pos OWNER TO synapse; + -- -- Name: deleted_pushers; Type: TABLE; Schema: public; Owner: synapse -- @@ -2266,6 +2300,57 @@ ALTER TABLE public.sliding_sync_connections ALTER COLUMN connection_key ADD GENE ); +-- +-- Name: sliding_sync_joined_rooms; Type: TABLE; Schema: public; Owner: synapse +-- + +CREATE TABLE public.sliding_sync_joined_rooms ( + room_id text NOT NULL, + event_stream_ordering bigint NOT NULL, + bump_stamp bigint, + room_type text, + room_name text, + is_encrypted boolean DEFAULT false NOT NULL, + tombstone_successor_room_id text +); + + +ALTER TABLE public.sliding_sync_joined_rooms OWNER TO synapse; + +-- +-- Name: sliding_sync_joined_rooms_to_recalculate; Type: TABLE; Schema: public; Owner: synapse +-- + +CREATE TABLE public.sliding_sync_joined_rooms_to_recalculate ( + room_id text NOT NULL +); + + +ALTER TABLE public.sliding_sync_joined_rooms_to_recalculate OWNER TO synapse; + +-- +-- Name: sliding_sync_membership_snapshots; Type: TABLE; Schema: public; Owner: synapse +-- + +CREATE TABLE public.sliding_sync_membership_snapshots ( + room_id text NOT NULL, + user_id text NOT NULL, + sender text NOT NULL, + membership_event_id text NOT NULL, + membership text NOT NULL, + forgotten integer DEFAULT 0 NOT NULL, + event_stream_ordering bigint NOT NULL, + event_instance_name text NOT NULL, + has_known_state boolean DEFAULT false NOT NULL, + room_type text, + room_name text, + is_encrypted boolean DEFAULT false NOT NULL, + tombstone_successor_room_id text +); + + +ALTER TABLE public.sliding_sync_membership_snapshots OWNER TO synapse; + -- -- Name: state_events; Type: TABLE; Schema: public; Owner: synapse -- @@ -2859,6 +2944,7 @@ ALTER TABLE ONLY public.instance_map ALTER COLUMN instance_id SET DEFAULT nextva COPY public.access_tokens (id, user_id, device_id, token, valid_until_ms, puppets_user_id, last_validated, refresh_token_id, used) FROM stdin; 2 @testuser1:synapse.local.dev BPMXXVDUCI syt_dGVzdHVzZXIx_GZuOBWyZRwLKNIAOieLt_0wO9QR \N \N 1725968857673 \N f 3 @testuser2:synapse.local.dev RXIMDAEIPS syt_dGVzdHVzZXIy_FKbuJWxXATBAWNYpwTRm_22Vqex \N \N 1725968863447 \N f +4 @admin:synapse.local.dev AVOHDDOCEU syt_YWRtaW4_wdeHUQwxaESbkyuItiOJ_2nphGL 1737304753154 \N 1737302953160 \N f \. @@ -3001,6 +3087,14 @@ COPY public.applied_schema_deltas (version, file) FROM stdin; 86 86/01_authenticate_media.sql 86 86/02_receipts_event_id_index.sql 87 87/02_per_connection_state.sql +87 87/01_sliding_sync_memberships.sql +87 87/03_current_state_index.sql +88 88/01_add_delayed_events.sql +88 88/02_fix_sliding_sync_membership_snapshots_forgotten_column.sql +88 88/03_add_otk_ts_added_index.sql +88 88/04_current_state_delta_index.sql +88 88/05_drop_old_otks.sql.postgres +88 88/05_sliding_sync_room_config_index.sql \. @@ -3042,26 +3136,9 @@ COPY public.blocked_rooms (room_id, user_id) FROM stdin; -- COPY public.cache_invalidation_stream_by_instance (stream_id, instance_name, cache_func, keys, invalidation_ts) FROM stdin; -2 master user_last_seen_monthly_active \N 1725968425148 -3 master get_monthly_active_count {} 1725968425151 -4 master get_user_by_id {@testuser1:synapse.local.dev} 1725968857659 -5 master get_user_by_id {@testuser2:synapse.local.dev} 1725968863438 -6 master count_e2e_one_time_keys {@testuser1:synapse.local.dev,NSMZZFGEGB} 1725968914316 -7 master get_e2e_unused_fallback_key_types {@testuser1:synapse.local.dev,NSMZZFGEGB} 1725968914319 -8 master _get_bare_e2e_cross_signing_keys {@testuser1:synapse.local.dev} 1725968914370 -9 master _get_bare_e2e_cross_signing_keys {@testuser1:synapse.local.dev} 1725968914375 -10 master _get_bare_e2e_cross_signing_keys {@testuser1:synapse.local.dev} 1725968914379 -11 master get_user_by_access_token {syt_dGVzdHVzZXIx_qnRcwingSiUqfdAAQGLF_2RZZpl} 1725968949948 -12 master count_e2e_one_time_keys {@testuser1:synapse.local.dev,NSMZZFGEGB} 1725968949951 -13 master get_e2e_unused_fallback_key_types {@testuser1:synapse.local.dev,NSMZZFGEGB} 1725968949952 -14 master count_e2e_one_time_keys {@testuser2:synapse.local.dev,VWJYXSFKXB} 1725968955550 -15 master get_e2e_unused_fallback_key_types {@testuser2:synapse.local.dev,VWJYXSFKXB} 1725968955553 -16 master _get_bare_e2e_cross_signing_keys {@testuser2:synapse.local.dev} 1725968955600 -17 master _get_bare_e2e_cross_signing_keys {@testuser2:synapse.local.dev} 1725968955604 -18 master _get_bare_e2e_cross_signing_keys {@testuser2:synapse.local.dev} 1725968955609 -19 master get_user_by_access_token {syt_dGVzdHVzZXIy_uiIOEaUaPUjsEZPnCsXy_30ruqe} 1725968990909 -20 master count_e2e_one_time_keys {@testuser2:synapse.local.dev,VWJYXSFKXB} 1725968990913 -21 master get_e2e_unused_fallback_key_types {@testuser2:synapse.local.dev,VWJYXSFKXB} 1725968990914 +22 master user_last_seen_monthly_active \N 1737302708830 +23 master get_monthly_active_count {} 1737302708831 +24 master get_user_by_id {@admin:synapse.local.dev} 1737302953148 \. @@ -3089,6 +3166,23 @@ COPY public.dehydrated_devices (user_id, device_id, device_data) FROM stdin; \. +-- +-- Data for Name: delayed_events; Type: TABLE DATA; Schema: public; Owner: synapse +-- + +COPY public.delayed_events (delay_id, user_localpart, device_id, delay, send_ts, room_id, event_type, state_key, origin_server_ts, content, is_processed) FROM stdin; +\. + + +-- +-- Data for Name: delayed_events_stream_pos; Type: TABLE DATA; Schema: public; Owner: synapse +-- + +COPY public.delayed_events_stream_pos (lock, stream_id) FROM stdin; +X 1 +\. + + -- -- Data for Name: deleted_pushers; Type: TABLE DATA; Schema: public; Owner: synapse -- @@ -3150,7 +3244,7 @@ COPY public.device_inbox (user_id, device_id, stream_id, message_json, instance_ -- COPY public.device_lists_changes_converted_stream_position (lock, stream_id, room_id, instance_name) FROM stdin; -X 17 master +X 18 master \. @@ -3223,6 +3317,7 @@ COPY public.device_lists_stream (stream_id, user_id, device_id, instance_name) F 14 @testuser2:synapse.local.dev 6eC6wF1kZa2jvwKKXYc8EQQVdfcTHwk6qrHuYfqzWlM master 15 @testuser2:synapse.local.dev QQZr70RkwRfInAf6B5PohDfYJynAvbqOquI0xjG4VDc master 17 @testuser2:synapse.local.dev VWJYXSFKXB master +18 @admin:synapse.local.dev AVOHDDOCEU master \. @@ -3239,6 +3334,7 @@ COPY public.devices (user_id, device_id, display_name, last_seen, ip, user_agent @testuser2:synapse.local.dev 6eC6wF1kZa2jvwKKXYc8EQQVdfcTHwk6qrHuYfqzWlM master signing key \N \N \N t @testuser2:synapse.local.dev QQZr70RkwRfInAf6B5PohDfYJynAvbqOquI0xjG4VDc self_signing signing key \N \N \N t @testuser2:synapse.local.dev +b3Uy1RlEu4Qki1BUfqZ0gjyJLtxNeBulbtUGAlC/Co user_signing signing key \N \N \N t +@admin:synapse.local.dev AVOHDDOCEU \N \N \N \N f \. @@ -3631,8 +3727,8 @@ COPY public.per_user_experimental_features (user_id, feature, enabled) FROM stdi -- COPY public.presence_stream (stream_id, user_id, state, last_active_ts, last_federation_update_ts, last_user_sync_ts, status_msg, currently_active, instance_name) FROM stdin; -5 @testuser2:synapse.local.dev online 1725968959331 1725968955489 1725968959331 \N t master 6 @testuser1:synapse.local.dev offline 1725968946810 1725968979664 1725968949968 \N t master +7 @testuser2:synapse.local.dev offline 1725968959331 1737302743693 1725968959331 \N t master \. @@ -3643,6 +3739,7 @@ COPY public.presence_stream (stream_id, user_id, state, last_active_ts, last_fed COPY public.profiles (user_id, displayname, avatar_url, full_user_id) FROM stdin; testuser1 testuser1 \N @testuser1:synapse.local.dev testuser2 testuser2 \N @testuser2:synapse.local.dev +admin admin \N @admin:synapse.local.dev \. @@ -3884,8 +3981,7 @@ COPY public.rooms (room_id, is_public, creator, room_version, has_auth_chain_ind -- COPY public.scheduled_tasks (id, action, status, "timestamp", resource_id, params, result, error) FROM stdin; -kmwbZCPxIgFRwtaG delete_device_messages complete 1725968949956 NSMZZFGEGB {"user_id":"@testuser1:synapse.local.dev","device_id":"NSMZZFGEGB","up_to_stream_id":1} \N \N -HYZEPGpnXbIIzPWk delete_device_messages complete 1725968990921 VWJYXSFKXB {"user_id":"@testuser2:synapse.local.dev","device_id":"VWJYXSFKXB","up_to_stream_id":1} \N \N +delete_old_otks_task delete_old_otks complete 1737302768709 \N \N \N \N \. @@ -3903,7 +3999,7 @@ X 84 -- COPY public.schema_version (lock, version, upgraded) FROM stdin; -X 87 t +X 88 t \. @@ -3971,6 +4067,30 @@ COPY public.sliding_sync_connections (connection_key, user_id, effective_device_ \. +-- +-- Data for Name: sliding_sync_joined_rooms; Type: TABLE DATA; Schema: public; Owner: synapse +-- + +COPY public.sliding_sync_joined_rooms (room_id, event_stream_ordering, bump_stamp, room_type, room_name, is_encrypted, tombstone_successor_room_id) FROM stdin; +\. + + +-- +-- Data for Name: sliding_sync_joined_rooms_to_recalculate; Type: TABLE DATA; Schema: public; Owner: synapse +-- + +COPY public.sliding_sync_joined_rooms_to_recalculate (room_id) FROM stdin; +\. + + +-- +-- Data for Name: sliding_sync_membership_snapshots; Type: TABLE DATA; Schema: public; Owner: synapse +-- + +COPY public.sliding_sync_membership_snapshots (room_id, user_id, sender, membership_event_id, membership, forgotten, event_stream_ordering, event_instance_name, has_known_state, room_type, room_name, is_encrypted, tombstone_successor_room_id) FROM stdin; +\. + + -- -- Data for Name: state_events; Type: TABLE DATA; Schema: public; Owner: synapse -- @@ -4026,9 +4146,9 @@ COPY public.stream_ordering_to_exterm (stream_ordering, room_id, event_id) FROM COPY public.stream_positions (stream_name, instance_name, stream_id) FROM stdin; e2e_cross_signing_keys master 7 -presence_stream master 6 account_data master 19 -device_lists_stream master 17 +presence_stream master 7 +device_lists_stream master 18 \. @@ -4127,6 +4247,7 @@ COPY public.user_daily_visits (user_id, device_id, "timestamp", user_agent) FROM COPY public.user_directory (user_id, room_id, display_name, avatar_url) FROM stdin; @testuser1:synapse.local.dev \N testuser1 \N @testuser2:synapse.local.dev \N testuser2 \N +@admin:synapse.local.dev \N admin \N \. @@ -4137,6 +4258,7 @@ COPY public.user_directory (user_id, room_id, display_name, avatar_url) FROM std COPY public.user_directory_search (user_id, vector) FROM stdin; @testuser1:synapse.local.dev 'synapse.local.dev':2 'testuser1':1A,3B @testuser2:synapse.local.dev 'synapse.local.dev':2 'testuser2':1A,3B +@admin:synapse.local.dev 'admin':1A,3B 'synapse.local.dev':2 \. @@ -4153,7 +4275,7 @@ COPY public.user_directory_stale_remote_users (user_id, user_server_name, next_t -- COPY public.user_directory_stream_pos (lock, stream_id) FROM stdin; -X -1 +X 1 \. @@ -4180,8 +4302,6 @@ testuser2 0 \\x7b22726f6f6d223a7b227374617465223a7b226c617a795f6c6f61645f6d656d6 -- COPY public.user_ips (user_id, access_token, device_id, ip, user_agent, last_seen) FROM stdin; -@testuser1:synapse.local.dev syt_dGVzdHVzZXIx_qnRcwingSiUqfdAAQGLF_2RZZpl NSMZZFGEGB ::ffff:172.18.0.1 Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 1725968914031 -@testuser2:synapse.local.dev syt_dGVzdHVzZXIy_uiIOEaUaPUjsEZPnCsXy_30ruqe VWJYXSFKXB ::ffff:172.18.0.1 Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 1725968955250 \. @@ -4202,6 +4322,7 @@ COPY public.user_signature_stream (stream_id, from_user_id, user_ids, instance_n COPY public.user_stats_current (user_id, joined_rooms, completed_delta_stream_id) FROM stdin; @testuser1:synapse.local.dev 0 -1 @testuser2:synapse.local.dev 0 -1 +@admin:synapse.local.dev 0 -1 \. @@ -4228,6 +4349,7 @@ COPY public.user_threepids (user_id, medium, address, validated_at, added_at) FR COPY public.users (name, password_hash, creation_ts, admin, upgrade_ts, is_guest, appservice_id, consent_version, consent_server_notice_sent, user_type, deactivated, shadow_banned, consent_ts, approved, locked, suspended) FROM stdin; @testuser1:synapse.local.dev $2b$12$ZCjwkIrkb5g76FMnuyZYZ.h3L3.tZEwZUasgi21wZww/IHhoJJXEO 1725968857 0 \N 0 \N \N \N \N 0 f \N t f f @testuser2:synapse.local.dev $2b$12$eZ9YU6VYPL5X.54/Luym9etjeOafqBf3.yMixOjF84taZOKOuYPze 1725968863 0 \N 0 \N \N \N \N 0 f \N t f f +@admin:synapse.local.dev $2b$12$5dKsMLWO4kw6FNySb6v6Dus5VuRa2/SrolFml.LDfU7uCACi.niri 1737302953 1 \N 0 \N \N \N \N 0 f \N t f f \. @@ -4305,7 +4427,7 @@ SELECT pg_catalog.setval('public.application_services_txn_id_seq', 1, false); -- Name: cache_invalidation_stream_seq; Type: SEQUENCE SET; Schema: public; Owner: synapse -- -SELECT pg_catalog.setval('public.cache_invalidation_stream_seq', 21, true); +SELECT pg_catalog.setval('public.cache_invalidation_stream_seq', 24, true); -- @@ -4319,7 +4441,7 @@ SELECT pg_catalog.setval('public.device_inbox_sequence', 1, true); -- Name: device_lists_sequence; Type: SEQUENCE SET; Schema: public; Owner: synapse -- -SELECT pg_catalog.setval('public.device_lists_sequence', 17, true); +SELECT pg_catalog.setval('public.device_lists_sequence', 18, true); -- @@ -4361,7 +4483,7 @@ SELECT pg_catalog.setval('public.instance_map_instance_id_seq', 1, false); -- Name: presence_stream_sequence; Type: SEQUENCE SET; Schema: public; Owner: synapse -- -SELECT pg_catalog.setval('public.presence_stream_sequence', 6, true); +SELECT pg_catalog.setval('public.presence_stream_sequence', 7, true); -- @@ -4538,6 +4660,22 @@ ALTER TABLE ONLY public.dehydrated_devices ADD CONSTRAINT dehydrated_devices_pkey PRIMARY KEY (user_id); +-- +-- Name: delayed_events delayed_events_pkey; Type: CONSTRAINT; Schema: public; Owner: synapse +-- + +ALTER TABLE ONLY public.delayed_events + ADD CONSTRAINT delayed_events_pkey PRIMARY KEY (user_localpart, delay_id); + + +-- +-- Name: delayed_events_stream_pos delayed_events_stream_pos_lock_key; Type: CONSTRAINT; Schema: public; Owner: synapse +-- + +ALTER TABLE ONLY public.delayed_events_stream_pos + ADD CONSTRAINT delayed_events_stream_pos_lock_key UNIQUE (lock); + + -- -- Name: destination_rooms destination_rooms_pkey; Type: CONSTRAINT; Schema: public; Owner: synapse -- @@ -5090,6 +5228,30 @@ ALTER TABLE ONLY public.sliding_sync_connections ADD CONSTRAINT sliding_sync_connections_pkey PRIMARY KEY (connection_key); +-- +-- Name: sliding_sync_joined_rooms sliding_sync_joined_rooms_pkey; Type: CONSTRAINT; Schema: public; Owner: synapse +-- + +ALTER TABLE ONLY public.sliding_sync_joined_rooms + ADD CONSTRAINT sliding_sync_joined_rooms_pkey PRIMARY KEY (room_id); + + +-- +-- Name: sliding_sync_joined_rooms_to_recalculate sliding_sync_joined_rooms_to_recalculate_pkey; Type: CONSTRAINT; Schema: public; Owner: synapse +-- + +ALTER TABLE ONLY public.sliding_sync_joined_rooms_to_recalculate + ADD CONSTRAINT sliding_sync_joined_rooms_to_recalculate_pkey PRIMARY KEY (room_id); + + +-- +-- Name: sliding_sync_membership_snapshots sliding_sync_membership_snapshots_pkey; Type: CONSTRAINT; Schema: public; Owner: synapse +-- + +ALTER TABLE ONLY public.sliding_sync_membership_snapshots + ADD CONSTRAINT sliding_sync_membership_snapshots_pkey PRIMARY KEY (room_id, user_id); + + -- -- Name: state_events state_events_event_id_key; Type: CONSTRAINT; Schema: public; Owner: synapse -- @@ -5289,6 +5451,13 @@ CREATE INDEX cache_invalidation_stream_by_instance_instance_index ON public.cach CREATE INDEX current_state_delta_stream_idx ON public.current_state_delta_stream USING btree (stream_id); +-- +-- Name: current_state_delta_stream_room_idx; Type: INDEX; Schema: public; Owner: synapse +-- + +CREATE INDEX current_state_delta_stream_room_idx ON public.current_state_delta_stream USING btree (room_id, stream_id); + + -- -- Name: current_state_events_member_index; Type: INDEX; Schema: public; Owner: synapse -- @@ -5296,6 +5465,13 @@ CREATE INDEX current_state_delta_stream_idx ON public.current_state_delta_stream CREATE INDEX current_state_events_member_index ON public.current_state_events USING btree (state_key) WHERE (type = 'm.room.member'::text); +-- +-- Name: current_state_events_members_room_index; Type: INDEX; Schema: public; Owner: synapse +-- + +CREATE INDEX current_state_events_members_room_index ON public.current_state_events USING btree (room_id, membership) WHERE (type = 'm.room.member'::text); + + -- -- Name: current_state_events_stream_ordering_idx; Type: INDEX; Schema: public; Owner: synapse -- @@ -5303,6 +5479,27 @@ CREATE INDEX current_state_events_member_index ON public.current_state_events US CREATE INDEX current_state_events_stream_ordering_idx ON public.current_state_events USING btree (event_stream_ordering); +-- +-- Name: delayed_events_is_processed; Type: INDEX; Schema: public; Owner: synapse +-- + +CREATE INDEX delayed_events_is_processed ON public.delayed_events USING btree (is_processed); + + +-- +-- Name: delayed_events_room_state_event_idx; Type: INDEX; Schema: public; Owner: synapse +-- + +CREATE INDEX delayed_events_room_state_event_idx ON public.delayed_events USING btree (room_id, event_type, state_key) WHERE (state_key IS NOT NULL); + + +-- +-- Name: delayed_events_send_ts; Type: INDEX; Schema: public; Owner: synapse +-- + +CREATE INDEX delayed_events_send_ts ON public.delayed_events USING btree (send_ts); + + -- -- Name: deleted_pushers_stream_id; Type: INDEX; Schema: public; Owner: synapse -- @@ -5485,6 +5682,13 @@ CREATE UNIQUE INDEX e2e_cross_signing_keys_stream_idx ON public.e2e_cross_signin CREATE INDEX e2e_cross_signing_signatures2_idx ON public.e2e_cross_signing_signatures USING btree (user_id, target_user_id, target_device_id); +-- +-- Name: e2e_one_time_keys_json_user_id_device_id_algorithm_ts_added_idx; Type: INDEX; Schema: public; Owner: synapse +-- + +CREATE INDEX e2e_one_time_keys_json_user_id_device_id_algorithm_ts_added_idx ON public.e2e_one_time_keys_json USING btree (user_id, device_id, algorithm, ts_added_ms); + + -- -- Name: e2e_room_keys_room_id; Type: INDEX; Schema: public; Owner: synapse -- @@ -6206,6 +6410,13 @@ CREATE INDEX sliding_sync_connection_required_state_conn_pos ON public.sliding_s CREATE UNIQUE INDEX sliding_sync_connection_room_configs_idx ON public.sliding_sync_connection_room_configs USING btree (connection_position, room_id); +-- +-- Name: sliding_sync_connection_room_configs_required_state_id_idx; Type: INDEX; Schema: public; Owner: synapse +-- + +CREATE INDEX sliding_sync_connection_room_configs_required_state_id_idx ON public.sliding_sync_connection_room_configs USING btree (required_state_id); + + -- -- Name: sliding_sync_connection_streams_idx; Type: INDEX; Schema: public; Owner: synapse -- @@ -6227,6 +6438,27 @@ CREATE INDEX sliding_sync_connections_idx ON public.sliding_sync_connections USI CREATE INDEX sliding_sync_connections_ts_idx ON public.sliding_sync_connections USING btree (created_ts); +-- +-- Name: sliding_sync_joined_rooms_event_stream_ordering; Type: INDEX; Schema: public; Owner: synapse +-- + +CREATE UNIQUE INDEX sliding_sync_joined_rooms_event_stream_ordering ON public.sliding_sync_joined_rooms USING btree (event_stream_ordering); + + +-- +-- Name: sliding_sync_membership_snapshots_event_stream_ordering; Type: INDEX; Schema: public; Owner: synapse +-- + +CREATE UNIQUE INDEX sliding_sync_membership_snapshots_event_stream_ordering ON public.sliding_sync_membership_snapshots USING btree (event_stream_ordering); + + +-- +-- Name: sliding_sync_membership_snapshots_user_id; Type: INDEX; Schema: public; Owner: synapse +-- + +CREATE INDEX sliding_sync_membership_snapshots_user_id ON public.sliding_sync_membership_snapshots USING btree (user_id); + + -- -- Name: state_group_edges_prev_idx; Type: INDEX; Schema: public; Owner: synapse -- @@ -6754,6 +6986,54 @@ ALTER TABLE ONLY public.sliding_sync_connection_streams ADD CONSTRAINT sliding_sync_connection_streams_connection_position_fkey FOREIGN KEY (connection_position) REFERENCES public.sliding_sync_connection_positions(connection_position) ON DELETE CASCADE; +-- +-- Name: sliding_sync_joined_rooms sliding_sync_joined_rooms_event_stream_ordering_fkey; Type: FK CONSTRAINT; Schema: public; Owner: synapse +-- + +ALTER TABLE ONLY public.sliding_sync_joined_rooms + ADD CONSTRAINT sliding_sync_joined_rooms_event_stream_ordering_fkey FOREIGN KEY (event_stream_ordering) REFERENCES public.events(stream_ordering); + + +-- +-- Name: sliding_sync_joined_rooms sliding_sync_joined_rooms_room_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: synapse +-- + +ALTER TABLE ONLY public.sliding_sync_joined_rooms + ADD CONSTRAINT sliding_sync_joined_rooms_room_id_fkey FOREIGN KEY (room_id) REFERENCES public.rooms(room_id); + + +-- +-- Name: sliding_sync_joined_rooms_to_recalculate sliding_sync_joined_rooms_to_recalculate_room_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: synapse +-- + +ALTER TABLE ONLY public.sliding_sync_joined_rooms_to_recalculate + ADD CONSTRAINT sliding_sync_joined_rooms_to_recalculate_room_id_fkey FOREIGN KEY (room_id) REFERENCES public.rooms(room_id); + + +-- +-- Name: sliding_sync_membership_snapshots sliding_sync_membership_snapshots_event_stream_ordering_fkey; Type: FK CONSTRAINT; Schema: public; Owner: synapse +-- + +ALTER TABLE ONLY public.sliding_sync_membership_snapshots + ADD CONSTRAINT sliding_sync_membership_snapshots_event_stream_ordering_fkey FOREIGN KEY (event_stream_ordering) REFERENCES public.events(stream_ordering); + + +-- +-- Name: sliding_sync_membership_snapshots sliding_sync_membership_snapshots_membership_event_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: synapse +-- + +ALTER TABLE ONLY public.sliding_sync_membership_snapshots + ADD CONSTRAINT sliding_sync_membership_snapshots_membership_event_id_fkey FOREIGN KEY (membership_event_id) REFERENCES public.events(event_id); + + +-- +-- Name: sliding_sync_membership_snapshots sliding_sync_membership_snapshots_room_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: synapse +-- + +ALTER TABLE ONLY public.sliding_sync_membership_snapshots + ADD CONSTRAINT sliding_sync_membership_snapshots_room_id_fkey FOREIGN KEY (room_id) REFERENCES public.rooms(room_id); + + -- -- Name: ui_auth_sessions_credentials ui_auth_sessions_credentials_session_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: synapse -- diff --git a/backend/resources/matrix/synapse/homeserver.template b/backend/resources/matrix/synapse/homeserver.template index 9d9423b6aa55e4192384ea370fa2e50bc7af2abc..312159645c63783b64acf7e096cc9266619dd133 100644 --- a/backend/resources/matrix/synapse/homeserver.template +++ b/backend/resources/matrix/synapse/homeserver.template @@ -15,6 +15,14 @@ listeners: - names: [client, federation] compress: false +email: + smtp_host: maildev + smtp_port: 1025 + smtp_user: "testuser" + smtp_pass: "testpassword" + require_transport_security: false + notif_from: "Your Friendly %(app)s homeserver <noreply@localhost.dev>" + database: name: psycopg2 args: @@ -98,10 +106,14 @@ oidc_providers: jwt_config: enabled: true - issuer: "http://localhost:4003/realms/eshg" algorithm: "RS256" secret: "$KEYCLOAK_RS256_PEM" +session_lifetime: 600m +refresh_token_lifetime: 600m # No need to have RefreshToken lifetime longer than 10 hours, because employee-portal will always log out user after 10 hours of *Activity* and requires user to re-authenticate with Keycloak. +refreshable_access_token_lifetime: 5m +nonrefreshable_access_token_lifetime: 5m + #templates: # custom_template_directory: "/data/templates/" # vim:ft=yaml diff --git a/backend/school-entry/openApi.json b/backend/school-entry/openApi.json index 73a5ea0741d0626a0e614a7368c6b93b670900f5..8fe44ab5aa66ef0b57cc69b24f9792d4f40e558f 100644 --- a/backend/school-entry/openApi.json +++ b/backend/school-entry/openApi.json @@ -4732,7 +4732,7 @@ }, "AppointmentType" : { "type" : "string", - "enum" : [ "CONSULTATION", "VACCINATION", "REGULAR_EXAMINATION", "CAN_CHILD", "ENTRY_LEVEL", "SPECIAL_NEEDS", "PROOF_SUBMISSION", "HIV_STI_CONSULTATION", "SEX_WORK", "RESULTS_REVIEW", "OFFICIAL_MEDICAL_SERVICE" ] + "enum" : [ "CONSULTATION", "VACCINATION", "REGULAR_EXAMINATION", "CAN_CHILD", "ENTRY_LEVEL", "SPECIAL_NEEDS", "PROOF_SUBMISSION", "HIV_STI_CONSULTATION", "SEX_WORK", "RESULTS_REVIEW", "OFFICIAL_MEDICAL_SERVICE_SHORT", "OFFICIAL_MEDICAL_SERVICE_LONG" ] }, "AppointmentTypeConfig" : { "required" : [ "appointmentTypeDto", "id", "standardDurationInMinutes" ], @@ -8936,7 +8936,11 @@ "type" : "integer", "format" : "int32" }, - "previousFileStateId" : { + "previousFacilityFileStateId" : { + "type" : "string", + "format" : "uuid" + }, + "previousPersonFileStateId" : { "type" : "string", "format" : "uuid" }, diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/PersonService.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/PersonService.java index 46e8ec325577d5fc694969231483258b9e8eeca3..8a3dda4d1e16f9672809301af8b35666c23191a2 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/PersonService.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/PersonService.java @@ -57,7 +57,7 @@ public class PersonService { if (!currentFileStateId.equals(updatedFileStateId)) { child.setCentralFileStateId(updatedFileStateId); - progressEntryUtil.addProgressEntryWithPreviousFileStateId( + progressEntryUtil.addProgressEntryWithPreviousPersonFileStateId( procedure, CHILD_MODIFIED, currentFileStateId); personRepository.flush(); } @@ -109,7 +109,7 @@ public class PersonService { if (!newCentralFileStateId.equals(centralFileStateId)) { person.setCentralFileStateId(newCentralFileStateId); - progressEntryUtil.addProgressEntryWithPreviousFileStateId( + progressEntryUtil.addProgressEntryWithPreviousPersonFileStateId( person.getProcedure(), CUSTODIAN_MODIFIED, centralFileStateId); personRepository.flush(); } 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 cbb8e4fece4cd256ed62351d8820bc045e086f23..36fd98294abf41a1ca12a3f8191026dd3e0a9647 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.procedure.api.ProcedureSearchParameters; import de.eshg.lib.procedure.domain.model.Pdf; import de.eshg.lib.procedure.domain.model.TaskType; import de.eshg.lib.procedure.util.ProcedureValidator; +import de.eshg.persistence.IntentionalWritingTransaction; import de.eshg.rest.service.error.BadRequestException; import de.eshg.rest.service.security.CurrentUserHelper; import de.eshg.rest.service.security.config.BaseUrls; @@ -144,6 +145,7 @@ public class SchoolEntryController { @GetMapping("/{procedureId}") @Transactional + @IntentionalWritingTransaction(reason = "Audit logging") @Operation(summary = "Get school entry procedure by id.") public ProcedureDetailsDto getProcedure(@PathVariable("procedureId") UUID procedureId) { ProcedureDetailsData procedureDetailsData = diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/SchoolEntryPublicCitizenController.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/SchoolEntryPublicCitizenController.java index 5275d1630d79060f58a32710b2bda22b78c61dc6..49c9afc91536d2ab806bb95416f71677f717da58 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/SchoolEntryPublicCitizenController.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/SchoolEntryPublicCitizenController.java @@ -5,6 +5,9 @@ package de.eshg.schoolentry; +import static de.eshg.rest.service.PrivacyDocumentHelper.privacyNoticeAttachmentResponse; +import static de.eshg.rest.service.PrivacyDocumentHelper.privacyPolicyAttachmentResponse; + import de.eshg.rest.service.security.config.BaseUrls; import de.eshg.schoolentry.api.citizen.GetOpeningHoursResponse; import de.eshg.schoolentry.config.SchoolEntryProperties; @@ -12,12 +15,8 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import java.net.MalformedURLException; import java.net.URI; -import java.nio.charset.StandardCharsets; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; -import org.springframework.http.ContentDisposition; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; @@ -60,26 +59,13 @@ public class SchoolEntryPublicCitizenController { @Operation(summary = "Get the privacy-notice document.") @Transactional(readOnly = true) public ResponseEntity<Resource> getPrivacyNotice() { - return getPrivacyDocument(privacyNotice, "Datenschutz-Information.pdf"); + return privacyNoticeAttachmentResponse(privacyNotice); } @GetMapping(path = "/documents/privacy-policy") @Operation(summary = "Get the privacy-policy document.") @Transactional(readOnly = true) public ResponseEntity<Resource> getPrivacyPolicy() { - return getPrivacyDocument(privacyPolicy, "Datenschutzerklaerung.pdf"); - } - - private static ResponseEntity<Resource> getPrivacyDocument( - Resource privacyDocument, String filename) { - return ResponseEntity.ok() - .header( - HttpHeaders.CONTENT_DISPOSITION, - ContentDisposition.attachment() - .filename(filename, StandardCharsets.UTF_8) - .build() - .toString()) - .contentType(MediaType.APPLICATION_PDF) - .body(privacyDocument); + return privacyPolicyAttachmentResponse(privacyPolicy); } } 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 6346fcf51f48066e83e689fa58bc8145c465c642..f1ebdb8d6ee44b20a6a4d636e9121dd7c0bb7dee 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 @@ -459,7 +459,7 @@ public class PersonClient { SchoolEntryProcedure procedure = updatedProceduresByChildId.get(previousCentralFileStateId); procedure.getChild().setCentralFileStateId(newCentralFileStateId); if (!Objects.equals(previousCentralFileStateId, newCentralFileStateId)) { - progressEntryUtil.addProgressEntryWithPreviousFileStateId( + progressEntryUtil.addProgressEntryWithPreviousPersonFileStateId( procedure, SchoolEntrySystemProgressEntryType.CHILD_MODIFIED, previousCentralFileStateId); diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/testhelper/SchoolEntryTestHelperResetAction.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/testhelper/SchoolEntryTestHelperResetAction.java new file mode 100644 index 0000000000000000000000000000000000000000..5155b61eb1aca8ac65d1ccf43b978f38ce74d977 --- /dev/null +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/testhelper/SchoolEntryTestHelperResetAction.java @@ -0,0 +1,34 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.schoolentry.testhelper; + +import de.eshg.lib.appointmentblock.persistence.CreateAppointmentTypeTask; +import de.eshg.schoolentry.population.CreateLabelsTask; +import de.eshg.testhelper.ConditionalOnTestHelperEnabled; +import de.eshg.testhelper.TestHelperServiceResetAction; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@ConditionalOnTestHelperEnabled +@Component +@Order(50) +public class SchoolEntryTestHelperResetAction implements TestHelperServiceResetAction { + + private final CreateAppointmentTypeTask createAppointmentTypeTask; + private final CreateLabelsTask createLabelsTask; + + public SchoolEntryTestHelperResetAction( + CreateAppointmentTypeTask createAppointmentTypeTask, CreateLabelsTask createLabelsTask) { + this.createAppointmentTypeTask = createAppointmentTypeTask; + this.createLabelsTask = createLabelsTask; + } + + @Override + public void reset() { + createAppointmentTypeTask.createAppointmentTypes(); + createLabelsTask.createLabels(); + } +} diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/testhelper/SchoolEntryTestHelperService.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/testhelper/SchoolEntryTestHelperService.java index 6fd595df56aed529d5686b60ae1fbd99cee6f52a..e4f8e3d37770c7923fa41679a24dface02a158bf 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/testhelper/SchoolEntryTestHelperService.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/testhelper/SchoolEntryTestHelperService.java @@ -5,16 +5,13 @@ package de.eshg.schoolentry.testhelper; -import de.eshg.lib.appointmentblock.persistence.CreateAppointmentTypeTask; import de.eshg.schoolentry.domain.model.SchoolEntryProcedure; import de.eshg.schoolentry.domain.repository.SchoolEntryProcedureRepository; -import de.eshg.schoolentry.population.CreateLabelsTask; import de.eshg.testhelper.*; import de.eshg.testhelper.environment.EnvironmentConfig; import de.eshg.testhelper.interception.TestRequestInterceptor; import de.eshg.testhelper.population.BasePopulator; import java.time.Clock; -import java.time.Instant; import java.util.List; import java.util.UUID; import org.springframework.stereotype.Service; @@ -23,8 +20,6 @@ import org.springframework.stereotype.Service; @Service public class SchoolEntryTestHelperService extends DefaultTestHelperService { - private final CreateAppointmentTypeTask createAppointmentTypeTask; - private final CreateLabelsTask createLabelsTask; private final SchoolEntryProcedureRepository schoolEntryProcedureRepository; protected SchoolEntryTestHelperService( @@ -33,9 +28,8 @@ public class SchoolEntryTestHelperService extends DefaultTestHelperService { Clock clock, List<BasePopulator<?>> populators, List<ResettableProperties> resettableProperties, - CreateAppointmentTypeTask createAppointmentTypeTask, - CreateLabelsTask createLabelsTask, SchoolEntryProcedureRepository schoolEntryProcedureRepository, + List<TestHelperServiceResetAction> resetActions, EnvironmentConfig environmentConfig) { super( databaseResetHelper, @@ -43,20 +37,11 @@ public class SchoolEntryTestHelperService extends DefaultTestHelperService { clock, populators, resettableProperties, + resetActions, environmentConfig); - this.createAppointmentTypeTask = createAppointmentTypeTask; - this.createLabelsTask = createLabelsTask; this.schoolEntryProcedureRepository = schoolEntryProcedureRepository; } - @Override - public Instant reset() throws Exception { - Instant instant = super.reset(); - createAppointmentTypeTask.createAppointmentTypes(); - createLabelsTask.createLabels(); - return instant; - } - public UUID getCitizenUserId(UUID procedureId) { SchoolEntryProcedure schoolEntryProcedure = schoolEntryProcedureRepository.findByExternalId(procedureId).orElseThrow(); diff --git a/backend/school-entry/src/main/java/de/eshg/schoolentry/util/ProgressEntryUtil.java b/backend/school-entry/src/main/java/de/eshg/schoolentry/util/ProgressEntryUtil.java index 399d81389f8fc4dd7ca2a93c54a6ed66c23b5c4d..dd57fb82e1cb5823474269ab7d6d008850e78baf 100644 --- a/backend/school-entry/src/main/java/de/eshg/schoolentry/util/ProgressEntryUtil.java +++ b/backend/school-entry/src/main/java/de/eshg/schoolentry/util/ProgressEntryUtil.java @@ -76,14 +76,14 @@ public class ProgressEntryUtil { progressEntryService.addSystemProgressEntry(procedure, progressEntry, file); } - public void addProgressEntryWithPreviousFileStateId( + public void addProgressEntryWithPreviousPersonFileStateId( SchoolEntryProcedure procedure, SchoolEntrySystemProgressEntryType progressEntryType, - UUID previousFileStateId) { + UUID previousPersonFileStateId) { SystemProgressEntry progressEntry = SystemProgressEntryFactory.createSystemProgressEntry( progressEntryType.name(), TriggerType.SYSTEM_AUTOMATIC); - progressEntry.setPreviousFileStateId(previousFileStateId); + progressEntry.setPreviousPersonFileStateId(previousPersonFileStateId); progressEntryService.addSystemProgressEntry(procedure, progressEntry); } diff --git a/backend/school-entry/src/main/resources/migrations/0079_differentiate_between_previous_person_and_facility_file_state.xml b/backend/school-entry/src/main/resources/migrations/0079_differentiate_between_previous_person_and_facility_file_state.xml new file mode 100644 index 0000000000000000000000000000000000000000..8729be621eb1c8a265e94bdcd231a834de5bd271 --- /dev/null +++ b/backend/school-entry/src/main/resources/migrations/0079_differentiate_between_previous_person_and_facility_file_state.xml @@ -0,0 +1,21 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 cronn GmbH + SPDX-License-Identifier: AGPL-3.0-only +--> + +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> + <changeSet author="GA-Lotse" id="1738231823333-1"> + <renameColumn tableName="system_progress_entry" + oldColumnName="previous_file_state_id" + newColumnName="previous_person_file_state_id"/> + <addColumn tableName="system_progress_entry"> + <column name="previous_facility_file_state_id" type="UUID"/> + </addColumn> + <addUniqueConstraint columnNames="previous_facility_file_state_id" + constraintName="system_progress_entry_previous_facility_file_state_id_key" + tableName="system_progress_entry"/> + </changeSet> +</databaseChangeLog> diff --git a/backend/school-entry/src/main/resources/migrations/0080_oms_appointment_type_extensions.xml b/backend/school-entry/src/main/resources/migrations/0080_oms_appointment_type_extensions.xml new file mode 100644 index 0000000000000000000000000000000000000000..e75345abe4787246577f3da4d3b7c46450d1ac20 --- /dev/null +++ b/backend/school-entry/src/main/resources/migrations/0080_oms_appointment_type_extensions.xml @@ -0,0 +1,11 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 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="1739261726737-1"> + <ext:modifyPostgresEnumType name="appointmenttype" newValues="CAN_CHILD, CONSULTATION, ENTRY_LEVEL, HIV_STI_CONSULTATION, OFFICIAL_MEDICAL_SERVICE_LONG, OFFICIAL_MEDICAL_SERVICE_SHORT, PROOF_SUBMISSION, REGULAR_EXAMINATION, RESULTS_REVIEW, SEX_WORK, SPECIAL_NEEDS, VACCINATION"/> + </changeSet> +</databaseChangeLog> diff --git a/backend/school-entry/src/main/resources/migrations/changelog.xml b/backend/school-entry/src/main/resources/migrations/changelog.xml index 1fbfda436c3fecb4e1848011cd2a5736d7946d8f..08b63ce4d47f44c32f910a44ef3254c8d4b3370c 100644 --- a/backend/school-entry/src/main/resources/migrations/changelog.xml +++ b/backend/school-entry/src/main/resources/migrations/changelog.xml @@ -86,5 +86,7 @@ <include file="migrations/0076_add_waiting_status.xml"/> <include file="migrations/0077_add_auditlog_entry.xml"/> <include file="migrations/0078_convert_duration_columns_to_interval.xml"/> + <include file="migrations/0079_differentiate_between_previous_person_and_facility_file_state.xml"/> + <include file="migrations/0080_oms_appointment_type_extensions.xml"/> </databaseChangeLog> diff --git a/backend/service-directory/src/main/java/de/eshg/servicedirectory/ServiceDirectoryAdminService.java b/backend/service-directory/src/main/java/de/eshg/servicedirectory/ServiceDirectoryAdminService.java index 737cbb31cd9ec8e1e1ae9d1ac3798136121fbf3e..3071ef812c9f9b5aeeaab8573f0398366a42cc3e 100644 --- a/backend/service-directory/src/main/java/de/eshg/servicedirectory/ServiceDirectoryAdminService.java +++ b/backend/service-directory/src/main/java/de/eshg/servicedirectory/ServiceDirectoryAdminService.java @@ -428,7 +428,7 @@ public class ServiceDirectoryAdminService { private void assertEmptyDatabase() { ExportResponse currentDatabaseContent = serviceDirectoryReadService.getAllForExport(false); - if (!currentDatabaseContent.orgUnits().isEmpty()) { + if (!currentDatabaseContent.isEmpty()) { throw new ServiceDirectoryBadRequestException("Import into non-empty database not allowed"); } } diff --git a/backend/service-directory/src/main/java/de/eshg/servicedirectory/ServiceDirectoryCommitService.java b/backend/service-directory/src/main/java/de/eshg/servicedirectory/ServiceDirectoryCommitService.java index b524c6f8c9dd2721c7696525187ac63ba89bf640..ea07c4b25a02a10891b1a7f5f9f787576c05d86b 100644 --- a/backend/service-directory/src/main/java/de/eshg/servicedirectory/ServiceDirectoryCommitService.java +++ b/backend/service-directory/src/main/java/de/eshg/servicedirectory/ServiceDirectoryCommitService.java @@ -206,7 +206,6 @@ public class ServiceDirectoryCommitService { .toList(); } - // TODO ISSUE-1921: we risk overwriting certificates set by postTopology here private void commit(StagedActor actor) { switch (actor.getStagedEntityType()) { case ADD -> commit(createNewAuditedActor(actor), actor); diff --git a/backend/service-directory/src/main/java/de/eshg/servicedirectory/actor/persistence/entity/AuditedActor.java b/backend/service-directory/src/main/java/de/eshg/servicedirectory/actor/persistence/entity/AuditedActor.java index d9e8042d5c267519afb5f328a56cee44f096c245..589976bddef8022a9d895f2ade7853ea18bc1fdb 100644 --- a/backend/service-directory/src/main/java/de/eshg/servicedirectory/actor/persistence/entity/AuditedActor.java +++ b/backend/service-directory/src/main/java/de/eshg/servicedirectory/actor/persistence/entity/AuditedActor.java @@ -9,8 +9,10 @@ import de.eshg.domain.model.GloballyUniqueEntityBase; import de.eshg.lib.common.DataSensitivity; import de.eshg.lib.common.SensitivityLevel; import de.eshg.servicedirectory.orgunit.persistence.entity.AuditedOrgUnit; +import de.eshg.servicedirectory.staging.persistence.entity.StagedInfo; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; @@ -73,6 +75,8 @@ public non-sealed class AuditedActor extends GloballyUniqueEntityBase implements orphanRemoval = true) private ActorMetadata actorMetadata; + @Embedded private final StagedInfo<StagedActor> stagedInfo = new StagedInfo<>(); + @Override public String getReadableName() { return readableName; diff --git a/backend/service-directory/src/main/java/de/eshg/servicedirectory/orgunit/persistence/entity/AuditedOrgUnit.java b/backend/service-directory/src/main/java/de/eshg/servicedirectory/orgunit/persistence/entity/AuditedOrgUnit.java index 467e28c816e5957918df85e86472e656c3130284..e96f6589a4c2e7bcbe8b7fe1a088988712a828d8 100644 --- a/backend/service-directory/src/main/java/de/eshg/servicedirectory/orgunit/persistence/entity/AuditedOrgUnit.java +++ b/backend/service-directory/src/main/java/de/eshg/servicedirectory/orgunit/persistence/entity/AuditedOrgUnit.java @@ -10,7 +10,9 @@ import de.eshg.lib.common.DataSensitivity; import de.eshg.lib.common.FederalState; import de.eshg.lib.common.SensitivityLevel; import de.eshg.servicedirectory.actor.persistence.entity.AuditedActor; +import de.eshg.servicedirectory.staging.persistence.entity.StagedInfo; import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.OneToMany; @@ -54,6 +56,8 @@ public non-sealed class AuditedOrgUnit extends GloballyUniqueEntityBase implemen @OrderBy private final List<AuditedActor> actors = new ArrayList<>(); + @Embedded private final StagedInfo<StagedOrgUnit> stagedInfo = new StagedInfo<>(); + @Override public String getReadableName() { return readableName; diff --git a/backend/service-directory/src/main/java/de/eshg/servicedirectory/rule/persistence/entity/AuditedRule.java b/backend/service-directory/src/main/java/de/eshg/servicedirectory/rule/persistence/entity/AuditedRule.java index 7f2550f0683ee7082133951197da1da8389a6286..e45df9b915e692ba67dc961dbec22296bcd0f6b9 100644 --- a/backend/service-directory/src/main/java/de/eshg/servicedirectory/rule/persistence/entity/AuditedRule.java +++ b/backend/service-directory/src/main/java/de/eshg/servicedirectory/rule/persistence/entity/AuditedRule.java @@ -8,6 +8,7 @@ package de.eshg.servicedirectory.rule.persistence.entity; import de.eshg.domain.model.GloballyUniqueEntityBase; import de.eshg.lib.common.DataSensitivity; import de.eshg.lib.common.SensitivityLevel; +import de.eshg.servicedirectory.staging.persistence.entity.StagedInfo; import jakarta.persistence.AttributeOverride; import jakarta.persistence.Column; import jakarta.persistence.Embedded; @@ -69,6 +70,8 @@ public non-sealed class AuditedRule extends GloballyUniqueEntityBase implements @Column(nullable = false) private Boolean active; + @Embedded private final StagedInfo<StagedRule> stagedInfo = new StagedInfo<>(); + @Override public String getDescription() { return description; diff --git a/backend/service-directory/src/main/java/de/eshg/servicedirectory/staging/persistence/entity/StagedInfo.java b/backend/service-directory/src/main/java/de/eshg/servicedirectory/staging/persistence/entity/StagedInfo.java new file mode 100644 index 0000000000000000000000000000000000000000..2849c9c0a2ae5363ad4ae58bd86375b3194fff82 --- /dev/null +++ b/backend/service-directory/src/main/java/de/eshg/servicedirectory/staging/persistence/entity/StagedInfo.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025 SCOOP Software GmbH, cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.servicedirectory.staging.persistence.entity; + +import de.eshg.domain.model.GloballyUniqueEntityBase; +import de.eshg.lib.common.DataSensitivity; +import de.eshg.lib.common.SensitivityLevel; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embeddable; +import jakarta.persistence.FetchType; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import java.util.ArrayList; +import java.util.List; +import org.hibernate.envers.NotAudited; + +@Embeddable +@DataSensitivity(SensitivityLevel.PUBLIC) +public class StagedInfo<T extends GloballyUniqueEntityBase> { + + @OneToMany( + mappedBy = "stagingInfo.auditedEntity", + cascade = CascadeType.REMOVE, + fetch = FetchType.LAZY) + @OrderBy + @NotAudited + private final List<T> stagedEntities = new ArrayList<>(); +} diff --git a/backend/service-directory/src/main/java/de/eshg/servicedirectory/staging/persistence/entity/StagingInfo.java b/backend/service-directory/src/main/java/de/eshg/servicedirectory/staging/persistence/entity/StagingInfo.java index 841f17fb2478079e61f599dc834a39baf4650e3e..f82551282f25b71da80d5b6e53d6d28510c57828 100644 --- a/backend/service-directory/src/main/java/de/eshg/servicedirectory/staging/persistence/entity/StagingInfo.java +++ b/backend/service-directory/src/main/java/de/eshg/servicedirectory/staging/persistence/entity/StagingInfo.java @@ -11,7 +11,7 @@ import de.eshg.lib.common.SensitivityLevel; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import jakarta.persistence.JoinColumn; -import jakarta.persistence.OneToOne; +import jakarta.persistence.ManyToOne; import java.util.UUID; import org.hibernate.annotations.JdbcType; import org.hibernate.dialect.PostgreSQLEnumJdbcType; @@ -23,7 +23,7 @@ public class StagingInfo<T extends GloballyUniqueEntityBase> implements StagedEn @JdbcType(PostgreSQLEnumJdbcType.class) private StagedEntityType stagedEntityType; - @OneToOne + @ManyToOne @JoinColumn(name = "audited_entity_id") private T auditedEntity; diff --git a/backend/service-directory/src/main/java/de/eshg/servicedirectory/testhelper/ServiceDirectoryTestHelperService.java b/backend/service-directory/src/main/java/de/eshg/servicedirectory/testhelper/ServiceDirectoryTestHelperService.java index 2c05c169f88f99fc3e2c8bc358b6922a8797629d..21d72fe88893b017bfd3eac63770c28c870cdebb 100644 --- a/backend/service-directory/src/main/java/de/eshg/servicedirectory/testhelper/ServiceDirectoryTestHelperService.java +++ b/backend/service-directory/src/main/java/de/eshg/servicedirectory/testhelper/ServiceDirectoryTestHelperService.java @@ -47,6 +47,7 @@ public class ServiceDirectoryTestHelperService extends DefaultTestHelperService OrgUnitPopulator orgUnitPopulator, ServiceDirectoryCommitService serviceDirectoryCommitService, ServiceDirectoryReadService serviceDirectoryReadService, + List<TestHelperServiceResetAction> resetActions, EnvironmentConfig environmentConfig) { super( databaseResetHelper, @@ -54,6 +55,7 @@ public class ServiceDirectoryTestHelperService extends DefaultTestHelperService clock, populators, resettableProperties, + resetActions, environmentConfig); this.orgUnitPopulator = orgUnitPopulator; this.serviceDirectoryCommitService = serviceDirectoryCommitService; diff --git a/backend/service-directory/src/main/resources/migrations/0009_correct_staged_entity_relation.xml b/backend/service-directory/src/main/resources/migrations/0009_correct_staged_entity_relation.xml new file mode 100644 index 0000000000000000000000000000000000000000..9f8d76e3a54db4e3c79251f729cb292663ec7110 --- /dev/null +++ b/backend/service-directory/src/main/resources/migrations/0009_correct_staged_entity_relation.xml @@ -0,0 +1,40 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 SCOOP Software GmbH, cronn GmbH + SPDX-License-Identifier: Apache-2.0 +--> + +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> + + <changeSet author="GA-Lotse" id="1727256941001-1"> + <dropUniqueConstraint + tableName="staged_actor" + constraintName="uq_staged_actor_audited_entity_id"/> + + <addUniqueConstraint + tableName="staged_actor" + columnNames="audited_entity_id" + constraintName="UQ_STAGED_ACTOR_AUDITED_ENTITY_ID"/> + + <dropUniqueConstraint + tableName="staged_org_unit" + constraintName="uq_staged_org_unit_audited_entity_id"/> + + <addUniqueConstraint + tableName="staged_org_unit" + columnNames="audited_entity_id" + constraintName="UQ_STAGED_ORG_UNIT_AUDITED_ENTITY_ID"/> + + <dropUniqueConstraint + tableName="staged_rule" + constraintName="uq_staged_rule_audited_entity_id"/> + + <addUniqueConstraint + tableName="staged_rule" + columnNames="audited_entity_id" + constraintName="UQ_STAGED_RULE_AUDITED_ENTITY_ID"/> + </changeSet> + +</databaseChangeLog> diff --git a/backend/service-directory/src/main/resources/migrations/changelog.xml b/backend/service-directory/src/main/resources/migrations/changelog.xml index 9a420e24180d9b4040768dcb2e7e171bf9cd8630..a2406c3cb509272d005cdd0d98b0a4095a813578 100644 --- a/backend/service-directory/src/main/resources/migrations/changelog.xml +++ b/backend/service-directory/src/main/resources/migrations/changelog.xml @@ -16,4 +16,5 @@ <include file="migrations/0006_add_unique_constraint_to_rule.xml"/> <include file="/migrations/0007_add_external_and_miscellaneous_actor_type.xml"/> <include file="migrations/0008_add_manual_cert_bool.xml"/> + <include file="migrations/0009_correct_staged_entity_relation.xml"/> </databaseChangeLog> diff --git a/backend/settings.gradle b/backend/settings.gradle index d32bd1139c05dc3255ffad70262c07ba4459fc8f..6f822a4825bec26736cb011426cce5c744156448 100644 --- a/backend/settings.gradle +++ b/backend/settings.gradle @@ -71,6 +71,7 @@ include 'lib-four-eyes-principle' include 'lib-four-eyes-principle-api' include 'lib-keycloak' include 'lib-lsd-api' +include 'lib-matrix-client' include 'lib-mutex' include 'lib-notification' include 'lib-notification-api' diff --git a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/AnalysisController.java b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/AnalysisController.java index d73d4c46635a12598b478cc1a99f9e8a6399aae0..e558a8618a27d13db3cee766e4a009b1e2873cf9 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/AnalysisController.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/AnalysisController.java @@ -15,6 +15,7 @@ import de.eshg.statistics.api.AnalysisWithDiagrams; import de.eshg.statistics.api.UpdateAnalysisRequest; import de.eshg.statistics.api.diagram.DiagramDto; import de.eshg.statistics.api.diagram.UpdateDiagramRequest; +import de.eshg.statistics.diagramcreation.DiagramCreationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; diff --git a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/AnalysisService.java b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/AnalysisService.java index 7dbbe6807b0c29c7d6d95f0055a6d5ae8824ebb4..7ab44dc5e188a93d911095d6314fb3745484e712 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/AnalysisService.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/AnalysisService.java @@ -5,20 +5,16 @@ package de.eshg.statistics.aggregation; -import de.eshg.domain.model.BaseEntity; import de.eshg.rest.service.error.BadRequestException; import de.eshg.rest.service.error.NotFoundException; -import de.eshg.statistics.GeoJsonHandler; import de.eshg.statistics.GeoShapeService; import de.eshg.statistics.api.AddAnalysisRequest; -import de.eshg.statistics.api.AddDiagramRequest; import de.eshg.statistics.api.AnalysisDto; import de.eshg.statistics.api.AnalysisWithDiagrams; import de.eshg.statistics.api.UpdateAnalysisRequest; import de.eshg.statistics.api.chart.AddChoroplethMapConfigurationDto; import de.eshg.statistics.api.chart.BarChartConfigurationDto; import de.eshg.statistics.api.chart.BinningModeDto; -import de.eshg.statistics.api.chart.CalculationDto; import de.eshg.statistics.api.chart.ChartConfigurationDto; import de.eshg.statistics.api.chart.ChoroplethMapConfigurationDto; import de.eshg.statistics.api.chart.HistogramChartConfigurationDto; @@ -29,21 +25,18 @@ import de.eshg.statistics.api.chart.ScatterChartConfigurationDto; import de.eshg.statistics.api.diagram.DiagramDto; import de.eshg.statistics.api.diagram.UpdateDiagramRequest; import de.eshg.statistics.api.filter.TableColumnFilterParameter; -import de.eshg.statistics.config.StatisticsConfig; import de.eshg.statistics.mapper.AnalysisMapper; import de.eshg.statistics.mapper.FilterParameterMapper; import de.eshg.statistics.persistence.entity.AbstractAggregationResult; import de.eshg.statistics.persistence.entity.AggregationResultPendingState; import de.eshg.statistics.persistence.entity.AggregationResultState; import de.eshg.statistics.persistence.entity.Analysis; -import de.eshg.statistics.persistence.entity.CellEntry; import de.eshg.statistics.persistence.entity.ChartConfiguration; import de.eshg.statistics.persistence.entity.Diagram; import de.eshg.statistics.persistence.entity.Evaluation; import de.eshg.statistics.persistence.entity.TableColumn; import de.eshg.statistics.persistence.entity.TableColumnValueType; import de.eshg.statistics.persistence.entity.TableRow; -import de.eshg.statistics.persistence.entity.ValueToMeaning; import de.eshg.statistics.persistence.entity.chart.ChoroplethMapConfiguration; import de.eshg.statistics.persistence.entity.chart.HistogramBin; import de.eshg.statistics.persistence.entity.chart.HistogramChartConfiguration; @@ -51,21 +44,11 @@ import de.eshg.statistics.persistence.entity.chart.LineChartConfiguration; import de.eshg.statistics.persistence.entity.chart.PieChartConfiguration; import de.eshg.statistics.persistence.entity.chart.ScatterChartConfiguration; import de.eshg.statistics.persistence.entity.diagramdata.BarChartData; -import de.eshg.statistics.persistence.entity.diagramdata.BarGroupData; import de.eshg.statistics.persistence.entity.diagramdata.ChoroplethMapData; -import de.eshg.statistics.persistence.entity.diagramdata.DataPoint; -import de.eshg.statistics.persistence.entity.diagramdata.DataPointGroup; import de.eshg.statistics.persistence.entity.diagramdata.DiagramData; import de.eshg.statistics.persistence.entity.diagramdata.HistogramChartData; -import de.eshg.statistics.persistence.entity.diagramdata.HistogramGroupData; -import de.eshg.statistics.persistence.entity.diagramdata.KeyToCount; -import de.eshg.statistics.persistence.entity.diagramdata.KeyToValue; import de.eshg.statistics.persistence.entity.diagramdata.LineOrScatterChartData; import de.eshg.statistics.persistence.entity.diagramdata.PieChartData; -import de.eshg.statistics.persistence.entity.diagramdata.TrendLine; -import de.eshg.statistics.persistence.entity.entry.BooleanEntry; -import de.eshg.statistics.persistence.entity.entry.DecimalEntry; -import de.eshg.statistics.persistence.entity.entry.IntegerEntry; import de.eshg.statistics.persistence.entity.evaluationtemplate.AnalysisTemplate; import de.eshg.statistics.persistence.entity.evaluationtemplate.DiagramTemplate; import de.eshg.statistics.persistence.entity.report.Report; @@ -75,28 +58,13 @@ import de.eshg.statistics.persistence.repository.TableRowRepository; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; -import java.util.Collection; import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; -import java.util.Map; -import java.util.Set; import java.util.UUID; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.apache.commons.lang3.StringUtils; import org.hibernate.Hibernate; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.CollectionUtils; @Service public class AnalysisService { @@ -113,21 +81,17 @@ public class AnalysisService { private final TableRowRepository tableRowRepository; private final DiagramRepository diagramRepository; - private final int pageSizeForCollectionDiagramData; - public AnalysisService( EvaluationService evaluationService, GeoShapeService geoShapeService, AnalysisRepository analysisRepository, TableRowRepository tableRowRepository, - DiagramRepository diagramRepository, - StatisticsConfig statisticsConfig) { + DiagramRepository diagramRepository) { this.evaluationService = evaluationService; this.geoShapeService = geoShapeService; this.analysisRepository = analysisRepository; this.tableRowRepository = tableRowRepository; this.diagramRepository = diagramRepository; - this.pageSizeForCollectionDiagramData = statisticsConfig.diagramData().pageSize(); } @Transactional(readOnly = true) @@ -628,888 +592,6 @@ public class AnalysisService { return AnalysisMapper.mapToApi(analysis, true); } - @Transactional(readOnly = true) - public int collectBarChartData( - Map<String, Map<String, Integer>> collectedChartData, - int page, - UUID analysisId, - List<TableColumnFilterParameter> filters, - BarChartConfigurationDto barChartConfigurationDto) { - Analysis analysis = getAnalysisInternal(analysisId); - AbstractAggregationResult aggregationResult = analysis.getAggregationResult(); - - TableColumn primaryTableColumn = - AggregationResultUtil.getTableColumn( - barChartConfigurationDto.primaryAttribute(), aggregationResult); - TableColumn secondaryTableColumn = - AggregationResultUtil.getTableColumn( - barChartConfigurationDto.secondaryAttribute(), aggregationResult); - if (page == 0) { - AggregationResultUtil.validateColumnFilters(filters, aggregationResult); - } - - Stream<Specification<TableRow>> notNullSpecifications; - if (secondaryTableColumn == null) { - notNullSpecifications = - Stream.of(TableRowSpecifications.getNotNullSpecification(primaryTableColumn)); - } else { - notNullSpecifications = - Stream.of( - TableRowSpecifications.getNotNullSpecification(primaryTableColumn), - TableRowSpecifications.getNotNullSpecification(secondaryTableColumn)); - } - - return collectDataForTablePageAndReturnMaxPage( - page, - notNullSpecifications, - filters, - aggregationResult, - tableRow -> - addTableRowToCollectedBarChartData( - tableRow, collectedChartData, primaryTableColumn, secondaryTableColumn)); - } - - private int collectDataForTablePageAndReturnMaxPage( - int page, - Stream<Specification<TableRow>> attributeSpecificationStream, - List<TableColumnFilterParameter> filters, - AbstractAggregationResult aggregationResult, - Consumer<TableRow> tableRowDataCollector) { - - Stream<Specification<TableRow>> attributePlusFilters = - Stream.concat( - attributeSpecificationStream, getFilterSpecificationStream(filters, aggregationResult)); - - Specification<TableRow> specification = - Specification.allOf( - Stream.concat( - Stream.of( - TableRowSpecifications.tableRowOfAggregationOrderByTableRowId( - aggregationResult)), - attributePlusFilters) - .toList()); - - Page<TableRow> tableRowPage = - tableRowRepository.findAll( - specification, PageRequest.of(page, pageSizeForCollectionDiagramData)); - - tableRowPage.get().forEach(tableRowDataCollector); - - long totalElements = tableRowPage.getTotalElements(); - if (totalElements % pageSizeForCollectionDiagramData == 0) { - return ((int) totalElements / pageSizeForCollectionDiagramData) - 1; - } else { - return (int) totalElements / pageSizeForCollectionDiagramData; - } - } - - private static Stream<Specification<TableRow>> getFilterSpecificationStream( - List<TableColumnFilterParameter> filters, AbstractAggregationResult aggregationResult) { - if (CollectionUtils.isEmpty(filters)) { - return Stream.empty(); - } - return filters.stream() - .map(filter -> TableRowSpecifications.createFilterSpecification(filter, aggregationResult)); - } - - private void addTableRowToCollectedBarChartData( - TableRow tableRow, - Map<String, Map<String, Integer>> collectedChartData, - TableColumn primaryTableColumn, - TableColumn secondaryTableColumn) { - String primaryKey = - getKeyForCellEntryBooleanTextOrValueOption(getCellEntry(tableRow, primaryTableColumn)); - - String secondaryKey; - if (secondaryTableColumn == null) { - secondaryKey = primaryKey; - } else { - secondaryKey = - getKeyForCellEntryBooleanTextOrValueOption(getCellEntry(tableRow, secondaryTableColumn)); - } - - addTableRowToCollectedChartData(primaryKey, secondaryKey, collectedChartData); - } - - private CellEntry getCellEntry(TableRow tableRow, TableColumn tableColumn) { - return tableRow.getCellEntries().stream() - .filter(cellEntry -> cellEntry.getTableColumn().getId().equals(tableColumn.getId())) - .findFirst() - .orElseThrow(); - } - - private String getKeyForCellEntryBooleanTextOrValueOption(CellEntry cellEntry) { - if (cellEntry.getValue() == null) { - return null; - } - if (cellEntry.getTableColumn().getValueType().equals(TableColumnValueType.BOOLEAN)) { - return Boolean.TRUE.equals(cellEntry.getValue()) ? "Ja" : "Nein"; - } - if (cellEntry.getTableColumn().getValueType().equals(TableColumnValueType.TEXT)) { - return cellEntry.getValue().toString(); - } - String stringValue = cellEntry.getValue().toString(); - if (cellEntry.getTableColumn().getValueType().equals(TableColumnValueType.VALUE_WITH_OPTIONS) - && getValueToMeaningKeys(cellEntry.getTableColumn()).contains(stringValue)) { - return stringValue; - } - return null; - } - - private static Set<String> getValueToMeaningKeys(TableColumn tableColumn) { - return tableColumn.getValueToMeanings().stream() - .map(ValueToMeaning::getValue) - .collect(Collectors.toSet()); - } - - private static <T> void addTableRowToCollectedChartData( - T primaryKey, String secondaryKey, Map<T, Map<String, Integer>> collectedChartData) { - if (primaryKey == null || secondaryKey == null) { - return; - } - - Map<String, Integer> secondaryToIntegerMap = - collectedChartData.computeIfAbsent(primaryKey, key -> new HashMap<>()); - secondaryToIntegerMap.compute(secondaryKey, (key, count) -> (count == null) ? 1 : count + 1); - } - - @Transactional - public UUID addBarChartDiagram( - UUID analysisId, - AddDiagramRequest addDiagramRequest, - Map<String, Map<String, Integer>> chartDataHolder, - BarChartConfigurationDto barChartConfigurationDto) { - Analysis analysis = getAnalysisInternal(analysisId); - fillBarChartDataWithMissingValues( - chartDataHolder, analysis.getAggregationResult(), barChartConfigurationDto); - - List<BarGroupData> groupDataList = getBarGroupDataList(chartDataHolder); - - int evaluatedEntries = - groupDataList.stream() - .map(BarGroupData::getKeyToCounts) - .flatMap(Collection::stream) - .mapToInt(KeyToCount::getCount) - .sum(); - - BarChartData barChartData = new BarChartData(); - barChartData.setEvaluatedDataAmount(evaluatedEntries); - barChartData.addBarGroupDatas(groupDataList); - - Diagram diagram = AnalysisMapper.mapToPersistence(addDiagramRequest, barChartData, analysis); - - analysisRepository.flush(); - return diagram.getExternalId(); - } - - private static void fillBarChartDataWithMissingValues( - Map<String, Map<String, Integer>> chartDataHolder, - AbstractAggregationResult aggregationResult, - BarChartConfigurationDto barChartConfigurationDto) { - TableColumn primaryTableColumn = - AggregationResultUtil.getTableColumn( - barChartConfigurationDto.primaryAttribute(), aggregationResult); - TableColumn secondaryTableColumn = - AggregationResultUtil.getTableColumn( - barChartConfigurationDto.secondaryAttribute(), aggregationResult); - - Set<String> primaryKeysBooleanValueOption = getKeysForBooleanOrValueOption(primaryTableColumn); - if (secondaryTableColumn == null) { - primaryKeysBooleanValueOption.forEach( - key -> - chartDataHolder.computeIfAbsent( - key, - secondaryKey -> { - Map<String, Integer> secondaryMap = new HashMap<>(); - secondaryMap.put(secondaryKey, 0); - return secondaryMap; - })); - } else { - Set<String> secondaryKeys; - if (secondaryTableColumn.getValueType().equals(TableColumnValueType.TEXT)) { - secondaryKeys = getKeysForTextValues(chartDataHolder); - } else { - secondaryKeys = getKeysForBooleanOrValueOption(secondaryTableColumn); - } - primaryKeysBooleanValueOption.forEach( - key -> chartDataHolder.computeIfAbsent(key, k -> new HashMap<>())); - - chartDataHolder - .keySet() - .forEach( - primaryKey -> { - Map<String, Integer> secondaryToIntegerMap = chartDataHolder.get(primaryKey); - secondaryKeys.forEach( - key -> secondaryToIntegerMap.computeIfAbsent(key, secondaryKey -> 0)); - }); - } - } - - private static <T> Set<String> getKeysForTextValues(Map<T, Map<String, Integer>> valueMap) { - Set<String> keys = new HashSet<>(); - valueMap.values().forEach(map -> keys.addAll(map.keySet())); - return keys; - } - - private static Set<String> getKeysForBooleanOrValueOption(TableColumn tableColumn) { - if (tableColumn == null) { - return Collections.emptySet(); - } - if (tableColumn.getValueType().equals(TableColumnValueType.BOOLEAN)) { - return Set.of("Ja", "Nein"); - } - if (tableColumn.getValueType().equals(TableColumnValueType.VALUE_WITH_OPTIONS)) { - return getValueToMeaningKeys(tableColumn); - } - return Collections.emptySet(); - } - - private static List<BarGroupData> getBarGroupDataList( - Map<String, Map<String, Integer>> chartDataHolder) { - Map<String, BarGroupData> groupDataMap = - chartDataHolder.entrySet().stream() - .map(entry -> mapToBarGroupData(entry.getKey(), entry.getValue())) - .collect(Collectors.toMap(BarGroupData::getKey, Function.identity())); - - return groupDataMap.keySet().stream().sorted().map(groupDataMap::get).toList(); - } - - private static BarGroupData mapToBarGroupData( - String key, Map<String, Integer> keyToCountStringIntegerMap) { - List<KeyToCount> keyToCounts = mapToSortedKeyToCountList(keyToCountStringIntegerMap); - - BarGroupData barGroupData = new BarGroupData(); - barGroupData.setKey(key); - barGroupData.addKeyToCounts(keyToCounts); - return barGroupData; - } - - private static List<KeyToCount> mapToSortedKeyToCountList( - Map<String, Integer> keyToCountStringIntegerMap) { - return keyToCountStringIntegerMap.entrySet().stream() - .map(AnalysisService::getKeyToCount) - .sorted(Comparator.comparing(KeyToCount::getKey)) - .toList(); - } - - private static KeyToCount getKeyToCount(Map.Entry<String, Integer> entry) { - KeyToCount keyToCount = new KeyToCount(); - keyToCount.setKey(entry.getKey()); - keyToCount.setCount(entry.getValue()); - return keyToCount; - } - - @Transactional(readOnly = true) - public Integer collectChoroplethMapData( - Map<String, List<BigDecimal>> collectedChartData, - Integer page, - UUID analysisId, - List<TableColumnFilterParameter> filters, - ChoroplethMapConfigurationDto choroplethMapConfigurationDto) { - Analysis analysis = getAnalysisInternal(analysisId); - AbstractAggregationResult aggregationResult = analysis.getAggregationResult(); - - TableColumn primaryTableColumn = - AggregationResultUtil.getTableColumn( - choroplethMapConfigurationDto.primaryAttribute(), aggregationResult); - TableColumn secondaryTableColumn = - AggregationResultUtil.getTableColumn( - choroplethMapConfigurationDto.secondaryAttribute(), aggregationResult); - List<String> geoKeys = GeoJsonHandler.getGeoKeys(choroplethMapConfigurationDto.geoJson()); - - if (page == 0) { - AggregationResultUtil.validateColumnFilters(filters, aggregationResult); - initializeChoroplethMapData(collectedChartData, geoKeys); - } - - List<Specification<TableRow>> specifications = - getNotNullSpecificationsForChoroplethMap(primaryTableColumn, secondaryTableColumn); - - specifications.add( - TableRowSpecifications.getValueOptionFilterSpecification( - primaryTableColumn, geoKeys, false)); - - return collectDataForTablePageAndReturnMaxPage( - page, - specifications.stream(), - filters, - aggregationResult, - tableRow -> - addTableRowToCollectedChoroplethMapData( - tableRow, collectedChartData, primaryTableColumn, secondaryTableColumn)); - } - - private void initializeChoroplethMapData( - Map<String, List<BigDecimal>> collectedChartData, List<String> geoKeys) { - geoKeys.forEach(geoKey -> collectedChartData.computeIfAbsent(geoKey, key -> new ArrayList<>())); - } - - private List<Specification<TableRow>> getNotNullSpecificationsForChoroplethMap( - TableColumn primaryTableColumn, TableColumn secondaryTableColumn) { - List<Specification<TableRow>> notNullSpecifications = new ArrayList<>(); - notNullSpecifications.add(TableRowSpecifications.getNotNullSpecification(primaryTableColumn)); - if (secondaryTableColumn != null) { - switch (secondaryTableColumn.getValueType()) { - case TableColumnValueType.BOOLEAN -> - notNullSpecifications.add( - TableRowSpecifications.getNotNullSpecification(secondaryTableColumn)); - case TableColumnValueType.DECIMAL, TableColumnValueType.INTEGER -> - notNullSpecifications.add( - TableRowSpecifications.getNotNullAndNotUnknownSpecificationDecimalAndInteger( - secondaryTableColumn)); - default -> - throw new IllegalStateException( - "Unexpected value type: " + secondaryTableColumn.getValueType()); - } - } - return notNullSpecifications; - } - - private void addTableRowToCollectedChoroplethMapData( - TableRow tableRow, - Map<String, List<BigDecimal>> collectedChartData, - TableColumn primaryTableColumn, - TableColumn secondaryTableColumn) { - String primaryKey = getKeyForTextOrValueOption(getCellEntry(tableRow, primaryTableColumn)); - - if (StringUtils.isBlank(primaryKey)) { - return; - } - BigDecimal value; - if (secondaryTableColumn == null) { - value = BigDecimal.ONE; - } else { - CellEntry cellEntry = getCellEntry(tableRow, secondaryTableColumn); - value = getValueAsBigDecimal(secondaryTableColumn.getValueType(), cellEntry); - } - - collectedChartData.computeIfAbsent(primaryKey, key -> new ArrayList<>()).add(value); - } - - private String getKeyForTextOrValueOption(CellEntry cellEntry) { - if (cellEntry.getValue() == null) { - return null; - } - - String stringValue = cellEntry.getValue().toString(); - return switch (cellEntry.getTableColumn().getValueType()) { - case TableColumnValueType.TEXT -> stringValue; - case TableColumnValueType.VALUE_WITH_OPTIONS -> { - if (getValueToMeaningKeys(cellEntry.getTableColumn()).contains(stringValue)) { - yield stringValue; - } else { - yield null; - } - } - default -> - throw new IllegalStateException( - "Unexpected value type: " + cellEntry.getTableColumn().getValueType()); - }; - } - - @Transactional - public UUID addChoroplethMapDiagram( - UUID analysisId, - AddDiagramRequest addDiagramRequest, - Map<String, List<BigDecimal>> data, - ChoroplethMapConfigurationDto choroplethMapConfigurationDto) { - Analysis analysis = getAnalysisInternal(analysisId); - - List<KeyToValue> keyToValues = new ArrayList<>(); - AtomicInteger evaluatedDataAmount = new AtomicInteger(0); - data.forEach( - (key, value) -> { - KeyToValue keyToValue = new KeyToValue(); - keyToValue.setKey(key); - BigDecimal sum = value.stream().reduce(BigDecimal.ZERO, BigDecimal::add); - if (CalculationDto.MEAN.equals(choroplethMapConfigurationDto.calculation())) { - BigDecimal mean = - value.isEmpty() - ? null - : sum.divide(new BigDecimal(value.size()), 4, RoundingMode.HALF_UP); - keyToValue.setValue(mean); - } else { - keyToValue.setValue(sum); - } - keyToValues.add(keyToValue); - evaluatedDataAmount.addAndGet(value.size()); - }); - - ChoroplethMapData choroplethMapData = new ChoroplethMapData(); - choroplethMapData.addKeyToValues(keyToValues); - choroplethMapData.setEvaluatedDataAmount(evaluatedDataAmount.get()); - - Diagram diagram = - AnalysisMapper.mapToPersistence(addDiagramRequest, choroplethMapData, analysis); - - analysisRepository.flush(); - return diagram.getExternalId(); - } - - @Transactional(readOnly = true) - public int collectHistogramChartData( - Map<Long, Map<String, Integer>> collectedChartData, - int page, - UUID analysisId, - List<TableColumnFilterParameter> filters, - HistogramChartConfigurationDto histogramChartConfigurationDto) { - Analysis analysis = getAnalysisInternal(analysisId); - AbstractAggregationResult aggregationResult = analysis.getAggregationResult(); - HistogramChartConfiguration chartConfiguration = - (HistogramChartConfiguration) - Hibernate.unproxy(analysis.getChartConfiguration(), ChartConfiguration.class); - - if (chartConfiguration.getBins().isEmpty()) { - return 0; - } - - TableColumn primaryTableColumn = - AggregationResultUtil.getTableColumn( - histogramChartConfigurationDto.primaryAttribute(), aggregationResult); - TableColumn secondaryTableColumn = - AggregationResultUtil.getTableColumn( - histogramChartConfigurationDto.secondaryAttribute(), aggregationResult); - if (page == 0) { - AggregationResultUtil.validateColumnFilters(filters, aggregationResult); - } - - Specification<TableRow> notNullNotUnknownSpecification = - TableRowSpecifications.getNotNullAndNotUnknownSpecificationDecimalAndInteger( - primaryTableColumn); - - Stream<Specification<TableRow>> specificationStream; - if (secondaryTableColumn == null) { - specificationStream = Stream.of(notNullNotUnknownSpecification); - } else { - specificationStream = - Stream.of( - notNullNotUnknownSpecification, - TableRowSpecifications.getNotNullSpecification(secondaryTableColumn)); - } - - return collectDataForTablePageAndReturnMaxPage( - page, - specificationStream, - filters, - aggregationResult, - tableRow -> - addTableRowToCollectedHistogramChartData( - tableRow, - collectedChartData, - chartConfiguration.getBins(), - primaryTableColumn, - secondaryTableColumn)); - } - - private void addTableRowToCollectedHistogramChartData( - TableRow tableRow, - Map<Long, Map<String, Integer>> collectedChartData, - List<HistogramBin> bins, - TableColumn primaryTableColumn, - TableColumn secondaryTableColumn) { - BigDecimal value = - getValueAsBigDecimal( - primaryTableColumn.getValueType(), getCellEntry(tableRow, primaryTableColumn)); - - Long primaryKey = - bins.stream() - .filter( - bin -> - (bin.getLowerBound().compareTo(value) <= 0) - && (bin.getUpperBound().compareTo(value) >= 0)) - .findFirst() - .map(BaseEntity::getId) - .orElse(null); - - String secondaryKey; - if (secondaryTableColumn == null) { - secondaryKey = String.valueOf(primaryKey); - } else { - secondaryKey = - getKeyForCellEntryBooleanTextOrValueOption(getCellEntry(tableRow, secondaryTableColumn)); - } - - addTableRowToCollectedChartData(primaryKey, secondaryKey, collectedChartData); - } - - private BigDecimal getValueAsBigDecimal(TableColumnValueType valueType, CellEntry cellEntry) { - return switch (valueType) { - case TableColumnValueType.BOOLEAN -> - Boolean.TRUE.equals(((BooleanEntry) cellEntry).getBoolValue()) - ? BigDecimal.ONE - : BigDecimal.ZERO; - case TableColumnValueType.DECIMAL -> ((DecimalEntry) cellEntry).getBigDecimalValue(); - case TableColumnValueType.INTEGER -> - new BigDecimal(((IntegerEntry) cellEntry).getIntegerValue()); - default -> throw new IllegalStateException("Unexpected value: " + valueType); - }; - } - - @Transactional - public UUID addHistogramChartDiagram( - UUID analysisId, - AddDiagramRequest addDiagramRequest, - Map<Long, Map<String, Integer>> chartDataHolder, - HistogramChartConfigurationDto histogramChartConfigurationDto) { - Analysis analysis = getAnalysisInternal(analysisId); - HistogramChartConfiguration chartConfiguration = - (HistogramChartConfiguration) - Hibernate.unproxy(analysis.getChartConfiguration(), ChartConfiguration.class); - fillHistogramChartDataWithMissingValues( - chartDataHolder, - chartConfiguration.getBins(), - analysis.getAggregationResult(), - histogramChartConfigurationDto); - - List<HistogramGroupData> histogramGroupDatas = - chartConfiguration.getBins().stream() - .map( - bin -> - mapToHistogramGroupData( - bin, - chartDataHolder, - histogramChartConfigurationDto.secondaryAttribute() != null)) - .toList(); - - int evaluatedEntries = - histogramGroupDatas.stream() - .map( - groupData -> { - if (groupData.getCount() == null) { - return groupData.getKeyToCounts().stream().mapToInt(KeyToCount::getCount).sum(); - } else { - return groupData.getCount(); - } - }) - .mapToInt(groupDataCount -> groupDataCount) - .sum(); - - HistogramChartData histogramChartData = new HistogramChartData(); - histogramChartData.setEvaluatedDataAmount(evaluatedEntries); - histogramChartData.addHistogramGroupDatas(histogramGroupDatas); - - Diagram diagram = - AnalysisMapper.mapToPersistence(addDiagramRequest, histogramChartData, analysis); - - analysisRepository.flush(); - return diagram.getExternalId(); - } - - private static void fillHistogramChartDataWithMissingValues( - Map<Long, Map<String, Integer>> chartDataHolder, - List<HistogramBin> bins, - AbstractAggregationResult aggregationResult, - HistogramChartConfigurationDto histogramChartConfigurationDto) { - TableColumn secondaryTableColumn = - AggregationResultUtil.getTableColumn( - histogramChartConfigurationDto.secondaryAttribute(), aggregationResult); - bins.forEach(bin -> chartDataHolder.computeIfAbsent(bin.getId(), k -> new HashMap<>())); - if (secondaryTableColumn == null) { - chartDataHolder.forEach( - (key, secondaryMap) -> { - String stringKey = String.valueOf(key); - secondaryMap.computeIfAbsent(stringKey, k -> 0); - }); - } else { - Set<String> secondaryKeys; - if (secondaryTableColumn.getValueType().equals(TableColumnValueType.TEXT)) { - secondaryKeys = getKeysForTextValues(chartDataHolder); - } else { - secondaryKeys = getKeysForBooleanOrValueOption(secondaryTableColumn); - } - chartDataHolder - .values() - .forEach( - secondaryMap -> - secondaryKeys.forEach(key -> secondaryMap.computeIfAbsent(key, k -> 0))); - } - } - - private HistogramGroupData mapToHistogramGroupData( - HistogramBin bin, - Map<Long, Map<String, Integer>> chartDataHolder, - boolean withSecondaryAttribute) { - HistogramGroupData histogramGroupData = new HistogramGroupData(); - bin.addHistogramGroupData(histogramGroupData); - - Map<String, Integer> dataForBin = chartDataHolder.get(bin.getId()); - if (withSecondaryAttribute) { - histogramGroupData.addKeyToCounts(mapToSortedKeyToCountList(dataForBin)); - } else { - histogramGroupData.setCount(dataForBin.values().stream().mapToInt(count -> count).sum()); - } - return histogramGroupData; - } - - @Transactional(readOnly = true) - public int collectPieChartData( - Map<String, Integer> collectedChartData, - int page, - UUID analysisId, - List<TableColumnFilterParameter> filters, - PieChartConfigurationDto pieChartConfigurationDto) { - Analysis analysis = getAnalysisInternal(analysisId); - AbstractAggregationResult aggregationResult = analysis.getAggregationResult(); - - TableColumn tableColumn = - AggregationResultUtil.getTableColumn( - pieChartConfigurationDto.attribute(), aggregationResult); - if (page == 0) { - AggregationResultUtil.validateColumnFilters(filters, aggregationResult); - initiallyFillPieChartMap(collectedChartData, tableColumn); - } - - Stream<Specification<TableRow>> notNullSpecifications = - Stream.of(TableRowSpecifications.getNotNullSpecification(tableColumn)); - - return collectDataForTablePageAndReturnMaxPage( - page, - notNullSpecifications, - filters, - aggregationResult, - tableRow -> addTableRowToCollectedPieChartData(tableRow, collectedChartData, tableColumn)); - } - - private void initiallyFillPieChartMap( - Map<String, Integer> collectedChartData, TableColumn tableColumn) { - Set<String> keys = getKeysForBooleanOrValueOption(tableColumn); - keys.forEach(key -> collectedChartData.put(key, 0)); - } - - private void addTableRowToCollectedPieChartData( - TableRow tableRow, Map<String, Integer> collectedChartData, TableColumn tableColumn) { - String primaryKey = - getKeyForCellEntryBooleanTextOrValueOption(getCellEntry(tableRow, tableColumn)); - if (primaryKey != null) { - collectedChartData.compute(primaryKey, (key, count) -> (count == null) ? 1 : count + 1); - } - } - - @Transactional - public UUID addPieChartDiagram( - UUID analysisId, AddDiagramRequest addDiagramRequest, Map<String, Integer> chartDataHolder) { - Analysis analysis = getAnalysisInternal(analysisId); - - List<KeyToCount> keyToCounts = mapToSortedKeyToCountList(chartDataHolder); - - int evaluatedEntries = keyToCounts.stream().mapToInt(KeyToCount::getCount).sum(); - - PieChartData pieChartData = new PieChartData(); - pieChartData.setEvaluatedDataAmount(evaluatedEntries); - pieChartData.addKeyToCounts(keyToCounts); - - Diagram diagram = AnalysisMapper.mapToPersistence(addDiagramRequest, pieChartData, analysis); - - analysisRepository.flush(); - return diagram.getExternalId(); - } - - @Transactional(readOnly = true) - public Integer collectPointBasedChartData( - Map<String, List<DataPointHolder>> collectedChartData, - Integer page, - UUID analysisId, - List<TableColumnFilterParameter> filters, - PointBasedChartConfigurationDto pointBasedChartConfiguration) { - Analysis analysis = getAnalysisInternal(analysisId); - AbstractAggregationResult aggregationResult = analysis.getAggregationResult(); - - TableColumn secondaryTableColumn = - AggregationResultUtil.getTableColumn( - pointBasedChartConfiguration.secondaryAttribute(), aggregationResult); - if (page == 0) { - AggregationResultUtil.validateColumnFilters(filters, aggregationResult); - initiallyFillPointBasedChartMap(collectedChartData, secondaryTableColumn); - } - - TableColumn xTableColumn = - AggregationResultUtil.getTableColumn( - pointBasedChartConfiguration.xAttribute(), aggregationResult); - TableColumn yTableColumn = - AggregationResultUtil.getTableColumn( - pointBasedChartConfiguration.yAttribute(), aggregationResult); - - List<Specification<TableRow>> notNullSpecifications = - getNotNullSpecificationsForDataPointCharts( - xTableColumn, yTableColumn, secondaryTableColumn); - - return collectDataForTablePageAndReturnMaxPage( - page, - notNullSpecifications.stream(), - filters, - aggregationResult, - tableRow -> - addTableRowToCollectedPointBasedChartData( - tableRow, collectedChartData, xTableColumn, yTableColumn, secondaryTableColumn)); - } - - private void initiallyFillPointBasedChartMap( - Map<String, List<DataPointHolder>> collectedChartData, TableColumn secondaryTableColumn) { - Set<String> secondaryKeys = getKeysForBooleanOrValueOption(secondaryTableColumn); - secondaryKeys.forEach(key -> collectedChartData.put(key, new ArrayList<>())); - } - - private List<Specification<TableRow>> getNotNullSpecificationsForDataPointCharts( - TableColumn xTableColumn, TableColumn yTableColumn, TableColumn secondaryTableColumn) { - List<Specification<TableRow>> notNullSpecifications = new ArrayList<>(); - notNullSpecifications.add( - TableRowSpecifications.getNotNullAndNotUnknownSpecificationDecimalAndInteger(xTableColumn)); - notNullSpecifications.add( - TableRowSpecifications.getNotNullAndNotUnknownSpecificationDecimalAndInteger(yTableColumn)); - - if (secondaryTableColumn != null) { - notNullSpecifications.add( - TableRowSpecifications.getNotNullSpecification(secondaryTableColumn)); - } - - return notNullSpecifications; - } - - private void addTableRowToCollectedPointBasedChartData( - TableRow tableRow, - Map<String, List<DataPointHolder>> collectedChartData, - TableColumn xTableColumn, - TableColumn yTableColumn, - TableColumn secondaryTableColumn) { - - BigDecimal xValue = - getValueAsBigDecimal(xTableColumn.getValueType(), getCellEntry(tableRow, xTableColumn)); - BigDecimal yValue = - getValueAsBigDecimal(yTableColumn.getValueType(), getCellEntry(tableRow, yTableColumn)); - - if (secondaryTableColumn == null) { - collectedChartData - .computeIfAbsent("", key -> new ArrayList<>()) - .add(new DataPointHolder(tableRow.getId(), xValue, yValue, null)); - } else { - CellEntry secondaryCellEntry = getCellEntry(tableRow, secondaryTableColumn); - String secondaryKey = getKeyForCellEntryBooleanTextOrValueOption(secondaryCellEntry); - if (secondaryKey != null) { - collectedChartData - .computeIfAbsent(secondaryKey, key -> new ArrayList<>()) - .add(new DataPointHolder(tableRow.getId(), xValue, yValue, secondaryKey)); - } - } - } - - @Transactional - public UUID addPointBasedChartDiagram( - UUID analysisId, - AddDiagramRequest addDiagramRequest, - Map<String, List<DataPointHolder>> data, - PointBasedChartConfigurationDto pointBasedChartConfiguration) { - Analysis analysis = getAnalysisInternal(analysisId); - - Comparator<DataPointHolder> comparator = - Comparator.comparing(DataPointHolder::xCoordinate) - .thenComparing(DataPointHolder::yCoordinate) - .thenComparing(DataPointHolder::rowId); - Function<DataPointHolder, DataPoint> mapFunction = - dataPointHolder -> - AnalysisService.getDataPoint( - dataPointHolder.xCoordinate(), dataPointHolder.yCoordinate()); - - AtomicInteger evaluatedDataAmount = new AtomicInteger(0); - List<DataPointGroup> dataPointGroups = new ArrayList<>(); - if (pointBasedChartConfiguration.secondaryAttribute() == null) { - List<DataPoint> dataPoints = - data.computeIfAbsent("", key -> new ArrayList<>()).stream() - .sorted(comparator) - .map(mapFunction) - .toList(); - DataPointGroup dataPointGroup = new DataPointGroup(); - dataPointGroup.addDataPoints(dataPoints); - dataPointGroups.add(dataPointGroup); - evaluatedDataAmount.addAndGet(dataPoints.size()); - } else { - data.keySet().stream() - .sorted() - .forEach( - key -> { - List<DataPoint> dataPoints = - data.get(key).stream().sorted(comparator).map(mapFunction).toList(); - DataPointGroup dataPointGroup = new DataPointGroup(); - dataPointGroup.setKey(key); - dataPointGroup.addDataPoints(dataPoints); - dataPointGroups.add(dataPointGroup); - evaluatedDataAmount.addAndGet(dataPoints.size()); - }); - } - - if (pointBasedChartConfiguration - instanceof ScatterChartConfigurationDto scatterChartConfigurationDto - && scatterChartConfigurationDto.trendLine()) { - dataPointGroups.forEach( - dataPointGroup -> dataPointGroup.setTrendLine(determineTrendLine(dataPointGroup))); - } - - LineOrScatterChartData lineOrScatterChartData = new LineOrScatterChartData(); - lineOrScatterChartData.addDataPointGroups(dataPointGroups); - lineOrScatterChartData.setEvaluatedDataAmount(evaluatedDataAmount.get()); - - Diagram diagram = - AnalysisMapper.mapToPersistence(addDiagramRequest, lineOrScatterChartData, analysis); - - analysisRepository.flush(); - return diagram.getExternalId(); - } - - private static DataPoint getDataPoint(BigDecimal xCoordinate, BigDecimal yCoordinate) { - DataPoint dataPoint = new DataPoint(); - dataPoint.setXCoordinate(xCoordinate); - dataPoint.setYCoordinate(yCoordinate); - return dataPoint; - } - - private static TrendLine determineTrendLine(DataPointGroup dataPointGroup) { - if (dataPointGroup.getDataPoints().size() < 2) { - return null; - } - - BigDecimal averageX = - calculateAverageOfDataPointCoordinate(dataPointGroup, DataPoint::getXCoordinate); - BigDecimal averageY = - calculateAverageOfDataPointCoordinate(dataPointGroup, DataPoint::getYCoordinate); - - BigDecimal numerator = - dataPointGroup.getDataPoints().stream() - .map( - dataPoint -> - dataPoint - .getXCoordinate() - .subtract(averageX) - .multiply(dataPoint.getYCoordinate().subtract(averageY))) - .reduce(BigDecimal::add) - .orElseThrow(); - BigDecimal denominator = - dataPointGroup.getDataPoints().stream() - .map(dataPoint -> dataPoint.getXCoordinate().subtract(averageX).pow(2)) - .reduce(BigDecimal::add) - .orElseThrow(); - - if (denominator.setScale(4, RoundingMode.HALF_UP).compareTo(BigDecimal.ZERO) == 0) { - return null; - } - - BigDecimal lineSlope = numerator.divide(denominator, RoundingMode.HALF_UP); - BigDecimal lineOffset = averageY.subtract(lineSlope.multiply(averageX)); - - TrendLine trendLine = new TrendLine(); - trendLine.setLineSlope(lineSlope.setScale(4, RoundingMode.HALF_UP)); - trendLine.setLineOffset(lineOffset.setScale(4, RoundingMode.HALF_UP)); - return trendLine; - } - - private static BigDecimal calculateAverageOfDataPointCoordinate( - DataPointGroup dataPointGroup, Function<DataPoint, BigDecimal> coordinateFunction) { - return dataPointGroup.getDataPoints().stream() - .map(coordinateFunction) - .reduce(BigDecimal::add) - .orElseThrow() - .setScale(8, RoundingMode.HALF_UP) - .divide(BigDecimal.valueOf(dataPointGroup.getDataPoints().size()), RoundingMode.HALF_UP); - } - @Transactional public DiagramDto updateDiagram(UUID diagramId, UpdateDiagramRequest updateDiagramRequest) { Diagram diagram = getDiagramInternal(diagramId); diff --git a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/DataAggregationService.java b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/DataAggregationService.java index 82bd31f360aac43e2d019a6f64030a5f3693eff7..c0ff256a88af063278d5477b81f39de365436e20 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/DataAggregationService.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/DataAggregationService.java @@ -12,6 +12,8 @@ import de.eshg.base.statistics.api.BaseAttribute; import de.eshg.base.statistics.api.BaseDataTableHeader; import de.eshg.base.statistics.api.GetBaseStatisticsDataRequest; import de.eshg.base.statistics.api.GetBaseStatisticsDataResponse; +import de.eshg.base.statistics.api.GetBaseStatisticsDataTableHeaderRequest; +import de.eshg.base.statistics.api.GetBaseStatisticsDataTableHeaderResponse; import de.eshg.base.statistics.api.SubjectType; import de.eshg.lib.aggregation.BusinessModuleAggregationHelper; import de.eshg.lib.aggregation.ClientResponse; @@ -21,6 +23,8 @@ import de.eshg.lib.statistics.api.DataPrivacyCategory; import de.eshg.lib.statistics.api.DataRow; import de.eshg.lib.statistics.api.DataSourceSensitivity; import de.eshg.lib.statistics.api.DataTableHeader; +import de.eshg.lib.statistics.api.GetDataTableHeaderRequest; +import de.eshg.lib.statistics.api.GetDataTableHeaderResponse; import de.eshg.lib.statistics.api.GetSpecificDataRequest; import de.eshg.lib.statistics.api.GetSpecificDataResponse; import de.eshg.lib.statistics.api.ValueType; @@ -35,6 +39,7 @@ import de.eshg.statistics.mapper.EvaluationMapper; import de.eshg.statistics.persistence.entity.AbstractAggregationResult; import de.eshg.statistics.persistence.entity.AggregationResultPendingState; import de.eshg.statistics.persistence.entity.AggregationResultState; +import de.eshg.statistics.persistence.entity.AnonymizationConfiguration; import de.eshg.statistics.persistence.entity.CellEntry; import de.eshg.statistics.persistence.entity.Evaluation; import de.eshg.statistics.persistence.entity.MinMaxNullUnknownValues; @@ -74,6 +79,9 @@ import org.springframework.stereotype.Service; @Service public class DataAggregationService { + private static final String ERROR_BUSINESS_MODULE_AGGREGATION = + "Could not retrieve data from business module"; + private final BusinessModuleAggregationHelper businessModuleAggregationHelper; private final BaseStatisticsApi baseModuleStatisticsApi; private final int businessModuleDataRequestPageSize; @@ -103,22 +111,17 @@ public class DataAggregationService { Instant timeRangeEnd, DataSourceSensitivity sensitivity, boolean anonymized) { - GetSpecificDataRequest request = - new GetSpecificDataRequest( + GetDataTableHeaderRequest request = + new GetDataTableHeaderRequest( timeRangeStart, timeRangeEnd, dataSource.id(), anonymized, - dataSource.attributeCodes().stream().map(BusinessDataAttribute::code).toList(), - 0, - 1); + dataSource.attributeCodes().stream().map(BusinessDataAttribute::code).toList()); - GetSpecificDataResponse dataFromBusinessModule = - getDataFromBusinessModule(request, dataSource.businessModuleName()); + GetDataTableHeaderResponse dataFromBusinessModule = + getDataTableHeaderFromBusinessModule(request, dataSource.businessModuleName()); - if (anonymized && !dataFromBusinessModule.anonymized()) { - throw new BadRequestException("Data was not anonymized"); - } if (!sensitivity.equals(dataFromBusinessModule.sensitivity())) { throw new BadRequestException( "Different sensitivities from business module, datasource %s - data %s" @@ -136,7 +139,13 @@ public class DataAggregationService { BusinessDataAttribute::code, BusinessDataAttribute::baseAttributeCodes)); Map<Integer, BaseStatisticsData> indexToBaseData = retrieveDataFromBase( - indexToBaseReferenceAttribute, codeToBaseAttributeCodes, Collections.emptyList()); + indexToBaseReferenceAttribute, + codeToBaseAttributeCodes, + Collections.emptyList(), + baseRetrievalInformation -> + retrieveDataTableHeaderFromBase( + baseRetrievalInformation.subjectType(), + baseRetrievalInformation.attributeCodes())); List<TableColumn> tableColumns = createTableColumns( @@ -162,39 +171,41 @@ public class DataAggregationService { evaluation.addTableColumns(tableColumns); evaluation.setNumberOfTableRows(0); evaluation.setState(AggregationResultState.CREATING); - evaluation.setPendingState( - dataFromBusinessModule.totalNumberOfElements() == 0 - ? AggregationResultPendingState.MIN_MAX_DETERMINATION - : AggregationResultPendingState.DATA_AGGREGATION); + evaluation.setPendingState(AggregationResultPendingState.DATA_AGGREGATION); return evaluation; } - private GetSpecificDataResponse getDataFromBusinessModule( - GetSpecificDataRequest businessModuleRequest, String businessModuleName) { - String message = "Could not retrieve data from business module"; + private GetDataTableHeaderResponse getDataTableHeaderFromBusinessModule( + GetDataTableHeaderRequest businessModuleRequest, String businessModuleName) { - List<ClientResponse<GetSpecificDataResponse>> clientResponses = + List<ClientResponse<GetDataTableHeaderResponse>> clientResponses = businessModuleAggregationHelper.requestFromBusinessModulesClients( Set.of(businessModuleName), null, - client -> client.getSpecificData(businessModuleRequest)); + client -> client.getDataTableHeader(businessModuleRequest)); if (clientResponses.isEmpty()) { - throw new BadRequestException(message); + throw new BadRequestException(ERROR_BUSINESS_MODULE_AGGREGATION); } - ClientResponse<GetSpecificDataResponse> clientResponse = clientResponses.getFirst(); - GetSpecificDataResponse getSpecificDataResponse = clientResponse.response(); - if (getSpecificDataResponse == null) { - ErrorResponseWithLocation errorResponseWithLocation = clientResponse.errorResponse(); - if (errorResponseWithLocation == null) { - throw new BadRequestException(message); - } else { - message += ": %s".formatted(errorResponseWithLocation.message()); - throw new BadRequestException(errorResponseWithLocation.errorCode(), message); - } + ClientResponse<GetDataTableHeaderResponse> clientResponse = clientResponses.getFirst(); + GetDataTableHeaderResponse getDataTableHeaderResponse = clientResponse.response(); + if (getDataTableHeaderResponse == null) { + handleAggregationError(clientResponse); + } + return getDataTableHeaderResponse; + } + + private static void handleAggregationError(ClientResponse<?> clientResponse) { + ErrorResponseWithLocation errorResponseWithLocation = clientResponse.errorResponse(); + if (errorResponseWithLocation == null) { + throw new BadRequestException(ERROR_BUSINESS_MODULE_AGGREGATION); + } else { + throw new BadRequestException( + errorResponseWithLocation.errorCode(), + ERROR_BUSINESS_MODULE_AGGREGATION + + ": %s".formatted(errorResponseWithLocation.message())); } - return getSpecificDataResponse; } private static Map<Integer, Attribute> findBaseModuleIdColumns(DataTableHeader dataTableHeader) { @@ -207,10 +218,23 @@ public class DataAggregationService { .collect(Collectors.toMap(index -> index, dataTableHeader.attributes()::get)); } + private BaseStatisticsData retrieveDataTableHeaderFromBase( + SubjectType subjectType, List<String> attributeCodes) { + GetBaseStatisticsDataTableHeaderRequest baseStatisticsDataTableRequest = + new GetBaseStatisticsDataTableHeaderRequest(subjectType.name(), attributeCodes); + + GetBaseStatisticsDataTableHeaderResponse dataTableHeaderResponse = + baseModuleStatisticsApi.getDataTableHeader(baseStatisticsDataTableRequest); + + return new BaseStatisticsData( + dataTableHeaderResponse.dataTableHeader(), Collections.emptyList()); + } + private Map<Integer, BaseStatisticsData> retrieveDataFromBase( Map<Integer, Attribute> indexToBaseReferenceAttribute, Map<String, List<String>> codeToBaseAttributeCodes, - List<DataRow> dataRows) { + List<DataRow> dataRows, + Function<BaseRetrievalInformation, BaseStatisticsData> baseRetrievalFunction) { Map<Integer, BaseStatisticsData> indexToDataFromBase = new HashMap<>(); indexToBaseReferenceAttribute.forEach( @@ -230,7 +254,10 @@ public class DataAggregationService { SubjectType subjectType = DataSourceAggregationService.mapToSubjectType(value.valueType()); indexToDataFromBase.put( - key, retrieveDataFromBase(subjectType, baseAttributeCodes, baseModuleIds)); + key, + baseRetrievalFunction.apply( + new BaseRetrievalInformation( + subjectType, baseAttributeCodes, baseModuleIds))); } } }); @@ -252,17 +279,6 @@ public class DataAggregationService { return null; } - private BaseStatisticsData retrieveDataFromBase( - SubjectType subjectType, List<String> attributeCodes, List<UUID> baseModuleIds) { - GetBaseStatisticsDataRequest baseStatisticsDataRequest = - new GetBaseStatisticsDataRequest(subjectType.name(), attributeCodes, baseModuleIds); - - GetBaseStatisticsDataResponse specificData = - baseModuleStatisticsApi.getSpecificData(baseStatisticsDataRequest); - - return new BaseStatisticsData(specificData.dataTableHeader(), specificData.dataRows()); - } - private static List<TableColumn> createTableColumns( String dataSourceName, String businessModuleName, @@ -312,24 +328,32 @@ public class DataAggregationService { tableColumn.setDataSourceName(dataSourceName); tableColumn.setDataSourceId(dataSourceId); + DataPrivacyCategory dataPrivacyCategory; if (baseModuleAttribute == null) { tableColumn.setValueType(mapToTableColumnValueType(businessModuleAttribute.valueType())); - tableColumn.setDataPrivacyCategory( - mapToTableColumnDataPrivacyCategory(businessModuleAttribute.dataPrivacyCategory())); tableColumn.setUnit(businessModuleAttribute.unit()); tableColumn.addValueToMeanings( EvaluationMapper.mapToValueToMeanings(businessModuleAttribute.valueOptions())); tableColumn.setMandatory(businessModuleAttribute.mandatory()); + + dataPrivacyCategory = businessModuleAttribute.dataPrivacyCategory(); } else { tableColumn.setBaseModuleAttributeCode(baseModuleAttribute.code()); tableColumn.setBaseModuleAttributeName(baseModuleAttribute.name()); tableColumn.setValueType(mapToTableColumnValueType(baseModuleAttribute.valueType())); - tableColumn.setDataPrivacyCategory( - mapToTableColumnDataPrivacyCategory(baseModuleAttribute.dataPrivacyCategory())); tableColumn.setUnit(baseModuleAttribute.unit()); tableColumn.addValueToMeanings( EvaluationMapper.mapToValueToMeanings(baseModuleAttribute.valueOptions())); tableColumn.setMandatory(baseModuleAttribute.mandatory()); + + dataPrivacyCategory = baseModuleAttribute.dataPrivacyCategory(); + } + + if (dataPrivacyCategory != null) { + AnonymizationConfiguration anonymizationConfiguration = new AnonymizationConfiguration(); + anonymizationConfiguration.setDataPrivacyCategory( + TableColumnDataPrivacyCategory.valueOf(dataPrivacyCategory.name())); + tableColumn.setAnonymizationConfiguration(anonymizationConfiguration); } tableColumn.setSearchKey( @@ -346,13 +370,6 @@ public class DataAggregationService { return TableColumnValueType.valueOf(valueType.name()); } - private static TableColumnDataPrivacyCategory mapToTableColumnDataPrivacyCategory( - DataPrivacyCategory dataPrivacyCategory) { - return dataPrivacyCategory == null - ? null - : TableColumnDataPrivacyCategory.valueOf(dataPrivacyCategory.name()); - } - private static List<TableColumn> createTableColumnsForBaseAttributes( String dataSourceName, String businessModuleName, @@ -414,7 +431,12 @@ public class DataAggregationService { retrieveDataFromBase( indexToBaseReferenceAttribute, codeToBaseAttributeCodes, - dataFromBusinessModule.dataRows()); + dataFromBusinessModule.dataRows(), + baseRetrievalInformation -> + retrieveSpecificDataFromBase( + baseRetrievalInformation.subjectType(), + baseRetrievalInformation.attributeCodes(), + baseRetrievalInformation.baseModuleIds())); validateAndUpdateTableColumns( aggregationResult, @@ -467,6 +489,25 @@ public class DataAggregationService { } } + private GetSpecificDataResponse getDataFromBusinessModule( + GetSpecificDataRequest businessModuleRequest, String businessModuleName) { + List<ClientResponse<GetSpecificDataResponse>> clientResponses = + businessModuleAggregationHelper.requestFromBusinessModulesClients( + Set.of(businessModuleName), + null, + client -> client.getSpecificData(businessModuleRequest)); + if (clientResponses.isEmpty()) { + throw new BadRequestException(ERROR_BUSINESS_MODULE_AGGREGATION); + } + + ClientResponse<GetSpecificDataResponse> clientResponse = clientResponses.getFirst(); + GetSpecificDataResponse getSpecificDataResponse = clientResponse.response(); + if (getSpecificDataResponse == null) { + handleAggregationError(clientResponse); + } + return getSpecificDataResponse; + } + private static void validateAnonymizationAndSensitivity( boolean dataNeedsAnonymization, GetSpecificDataResponse dataFromBusinessModule, @@ -483,6 +524,17 @@ public class DataAggregationService { } } + private BaseStatisticsData retrieveSpecificDataFromBase( + SubjectType subjectType, List<String> attributeCodes, List<UUID> baseModuleIds) { + GetBaseStatisticsDataRequest baseStatisticsDataRequest = + new GetBaseStatisticsDataRequest(subjectType.name(), attributeCodes, baseModuleIds); + + GetBaseStatisticsDataResponse specificData = + baseModuleStatisticsApi.getSpecificData(baseStatisticsDataRequest); + + return new BaseStatisticsData(specificData.dataTableHeader(), specificData.dataRows()); + } + private static List<String> getBusinessModuleAttributeCodes( AbstractAggregationResult aggregationResult) { Set<String> codesAdded = new HashSet<>(); @@ -523,6 +575,9 @@ public class DataAggregationService { updateValueToMeaningIfAllowed( currentTableColumn, newTableColumn.getValueToMeanings()); + + updateAnonymizationConfiguration( + currentTableColumn, newTableColumn.getAnonymizationConfiguration()); }); if (IntStream.range(0, aggregationResult.getTableColumns().size()) @@ -556,6 +611,23 @@ public class DataAggregationService { } } + private static void updateAnonymizationConfiguration( + TableColumn currentTableColumn, AnonymizationConfiguration newConfiguration) { + if (newConfiguration == null) { + currentTableColumn.setAnonymizationConfiguration(null); + } else { + AnonymizationConfiguration currentConfiguration; + if (currentTableColumn.getAnonymizationConfiguration() == null) { + currentConfiguration = new AnonymizationConfiguration(); + currentTableColumn.setAnonymizationConfiguration(currentConfiguration); + } else { + currentConfiguration = currentTableColumn.getAnonymizationConfiguration(); + } + + EvaluationCopyService.copyAnonymizationConfiguration(currentConfiguration, newConfiguration); + } + } + private static boolean isDifferentTableColumn( TableColumn firstTableColumn, TableColumn secondTableColumn) { if (!firstTableColumn.getBusinessModuleName().equals(secondTableColumn.getBusinessModuleName()) @@ -568,10 +640,7 @@ public class DataAggregationService { firstTableColumn.getBaseModuleAttributeCode(), secondTableColumn.getBaseModuleAttributeCode()) || !Objects.equals(firstTableColumn.getUnit(), secondTableColumn.getUnit()) - || firstTableColumn.isMandatory() != secondTableColumn.isMandatory() - || !Objects.equals( - firstTableColumn.getDataPrivacyCategory(), - secondTableColumn.getDataPrivacyCategory())) { + || firstTableColumn.isMandatory() != secondTableColumn.isMandatory()) { return true; } if (firstTableColumn.getValueToMeanings().size() @@ -961,6 +1030,9 @@ public class DataAggregationService { .collect(Collectors.joining(", "))); } + private record BaseRetrievalInformation( + SubjectType subjectType, List<String> attributeCodes, List<UUID> baseModuleIds) {} + private record BaseStatisticsData(BaseDataTableHeader dataTableHeader, List<DataRow> dataRows) {} private record MergeInformation( diff --git a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/DiagramCreationService.java b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/DiagramCreationService.java deleted file mode 100644 index 253dcb8aab015ed21399136720b74e8c2b3e9dd3..0000000000000000000000000000000000000000 --- a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/DiagramCreationService.java +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package de.eshg.statistics.aggregation; - -import de.eshg.statistics.api.AddDiagramRequest; -import de.eshg.statistics.api.AnalysisDto; -import de.eshg.statistics.api.chart.BarChartConfigurationDto; -import de.eshg.statistics.api.chart.ChoroplethMapConfigurationDto; -import de.eshg.statistics.api.chart.HistogramChartConfigurationDto; -import de.eshg.statistics.api.chart.LineChartConfigurationDto; -import de.eshg.statistics.api.chart.PieChartConfigurationDto; -import de.eshg.statistics.api.chart.PointBasedChartConfigurationDto; -import de.eshg.statistics.api.chart.ScatterChartConfigurationDto; -import de.eshg.statistics.mapper.AnalysisMapper; -import de.eshg.statistics.mapper.FilterParameterMapper; -import de.eshg.statistics.persistence.entity.AggregationResultState; -import de.eshg.statistics.persistence.entity.Evaluation; -import java.math.BigDecimal; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; -import java.util.UUID; -import java.util.function.BiFunction; -import java.util.function.Function; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -public class DiagramCreationService { - private final AnalysisService analysisService; - private final EvaluationService evaluationService; - - public DiagramCreationService( - AnalysisService analysisService, EvaluationService evaluationService) { - this.analysisService = analysisService; - this.evaluationService = evaluationService; - } - - public UUID createDiagram(AnalysisDto analysisDto, AddDiagramRequest addDiagramRequest) { - UUID analysisId = analysisDto.id(); - - return switch (analysisDto.chartConfiguration()) { - case BarChartConfigurationDto barChartConfigurationDto -> - addBarChartDiagramWithData(analysisId, addDiagramRequest, barChartConfigurationDto); - case ChoroplethMapConfigurationDto choroplethMapConfigurationDto -> - addChoroplethMapWithData(analysisId, addDiagramRequest, choroplethMapConfigurationDto); - case HistogramChartConfigurationDto histogramChartConfigurationDto -> - addHistogramChartDiagramWithData( - analysisId, addDiagramRequest, histogramChartConfigurationDto); - case LineChartConfigurationDto lineChartConfigurationDto -> - addPointBasedChartDiagramWithData( - analysisId, addDiagramRequest, lineChartConfigurationDto); - case PieChartConfigurationDto pieChartConfigurationDto -> - addPieChartDiagramWithData(analysisId, addDiagramRequest, pieChartConfigurationDto); - case ScatterChartConfigurationDto scatterChartConfigurationDto -> - addPointBasedChartDiagramWithData( - analysisId, addDiagramRequest, scatterChartConfigurationDto); - }; - } - - private UUID addBarChartDiagramWithData( - UUID analysisId, - AddDiagramRequest addDiagramRequest, - BarChartConfigurationDto barChartConfigurationDto) { - Map<String, Map<String, Integer>> chartDataHolder = new HashMap<>(); - - BiFunction<Map<String, Map<String, Integer>>, Integer, Integer> collectDataFunction = - (data, page) -> - analysisService.collectBarChartData( - data, page, analysisId, addDiagramRequest.filters(), barChartConfigurationDto); - - Function<Map<String, Map<String, Integer>>, UUID> addDiagramFunction = - data -> - analysisService.addBarChartDiagram( - analysisId, addDiagramRequest, data, barChartConfigurationDto); - return collectDiagramDataAndAddDiagram( - chartDataHolder, collectDataFunction, addDiagramFunction); - } - - private UUID addChoroplethMapWithData( - UUID analysisId, - AddDiagramRequest addDiagramRequest, - ChoroplethMapConfigurationDto choroplethMapConfigurationDto) { - Map<String, List<BigDecimal>> chartDataHolder = new TreeMap<>(); - - BiFunction<Map<String, List<BigDecimal>>, Integer, Integer> collectDataFunction = - (data, page) -> - analysisService.collectChoroplethMapData( - data, page, analysisId, addDiagramRequest.filters(), choroplethMapConfigurationDto); - - Function<Map<String, List<BigDecimal>>, UUID> addDiagramFunction = - data -> - analysisService.addChoroplethMapDiagram( - analysisId, addDiagramRequest, data, choroplethMapConfigurationDto); - return collectDiagramDataAndAddDiagram( - chartDataHolder, collectDataFunction, addDiagramFunction); - } - - private UUID addHistogramChartDiagramWithData( - UUID analysisId, - AddDiagramRequest addDiagramRequest, - HistogramChartConfigurationDto histogramChartConfigurationDto) { - Map<Long, Map<String, Integer>> chartDataHolder = new HashMap<>(); - - BiFunction<Map<Long, Map<String, Integer>>, Integer, Integer> collectDataFunction = - (data, page) -> - analysisService.collectHistogramChartData( - data, - page, - analysisId, - addDiagramRequest.filters(), - histogramChartConfigurationDto); - - Function<Map<Long, Map<String, Integer>>, UUID> addDiagramFunction = - data -> - analysisService.addHistogramChartDiagram( - analysisId, addDiagramRequest, data, histogramChartConfigurationDto); - return collectDiagramDataAndAddDiagram( - chartDataHolder, collectDataFunction, addDiagramFunction); - } - - private UUID addPieChartDiagramWithData( - UUID analysisId, - AddDiagramRequest addDiagramRequest, - PieChartConfigurationDto pieChartConfigurationDto) { - Map<String, Integer> chartDataHolder = new HashMap<>(); - - BiFunction<Map<String, Integer>, Integer, Integer> collectDataFunction = - (data, page) -> - analysisService.collectPieChartData( - data, page, analysisId, addDiagramRequest.filters(), pieChartConfigurationDto); - - Function<Map<String, Integer>, UUID> addDiagramFunction = - data -> analysisService.addPieChartDiagram(analysisId, addDiagramRequest, data); - - return collectDiagramDataAndAddDiagram( - chartDataHolder, collectDataFunction, addDiagramFunction); - } - - private UUID addPointBasedChartDiagramWithData( - UUID analysisId, - AddDiagramRequest addDiagramRequest, - PointBasedChartConfigurationDto pointBasedChartConfiguration) { - Map<String, List<DataPointHolder>> chartDataHolder = new HashMap<>(); - - BiFunction<Map<String, List<DataPointHolder>>, Integer, Integer> collectDataFunction = - (data, page) -> - analysisService.collectPointBasedChartData( - data, page, analysisId, addDiagramRequest.filters(), pointBasedChartConfiguration); - - Function<Map<String, List<DataPointHolder>>, UUID> addDiagramFunction = - data -> - analysisService.addPointBasedChartDiagram( - analysisId, addDiagramRequest, data, pointBasedChartConfiguration); - - return collectDiagramDataAndAddDiagram( - chartDataHolder, collectDataFunction, addDiagramFunction); - } - - private <T> UUID collectDiagramDataAndAddDiagram( - T chartDataHolder, - BiFunction<T, Integer, Integer> collectDataFunction, - Function<T, UUID> addDiagramFunction) { - int page = 0; - int maxPage; - while (true) { - maxPage = collectDataFunction.apply(chartDataHolder, page); - if (page >= maxPage) { - break; - } - page++; - } - - return addDiagramFunction.apply(chartDataHolder); - } - - @Transactional - public void diagramRecreation(UUID evaluationId) { - Evaluation evaluation = evaluationService.getEvaluationInternal(evaluationId); - recreateDiagrams(evaluation); - evaluation.setPendingState(null); - evaluation.setState(AggregationResultState.COMPLETED); - } - - private void recreateDiagrams(Evaluation evaluation) { - evaluation - .getAnalyses() - .forEach( - analysis -> { - AnalysisDto analysisDto = AnalysisMapper.mapToApi(analysis, true); - List<AddDiagramRequest> addDiagramRequests = - analysis.getDiagrams().stream() - .map( - diagram -> - new AddDiagramRequest( - diagram.getTitle(), - diagram.getDescription(), - FilterParameterMapper.mapToApi(diagram.getFilters()))) - .toList(); - analysis.removeDiagrams(); - addDiagramRequests.forEach( - addDiagramRequest -> createDiagram(analysisDto, addDiagramRequest)); - }); - } -} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/EvaluationCopyService.java b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/EvaluationCopyService.java index 1a3f05c2407505fa68c22e42fd4e3b613c9baecf..bbc37c7e8b280f8cace5ad550889b944f7bc8a08 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/EvaluationCopyService.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/EvaluationCopyService.java @@ -10,6 +10,7 @@ import de.eshg.statistics.persistence.entity.AbstractFilterParameter; import de.eshg.statistics.persistence.entity.AggregationResultPendingState; import de.eshg.statistics.persistence.entity.AggregationResultState; import de.eshg.statistics.persistence.entity.Analysis; +import de.eshg.statistics.persistence.entity.AnonymizationConfiguration; import de.eshg.statistics.persistence.entity.AttributeSelection; import de.eshg.statistics.persistence.entity.CellEntry; import de.eshg.statistics.persistence.entity.ChartConfiguration; @@ -54,8 +55,10 @@ import de.eshg.statistics.persistence.entity.filter.NullFilterParameter; import de.eshg.statistics.persistence.entity.filter.TextFilterParameter; import de.eshg.statistics.persistence.entity.filter.ValueOptionFilterParameter; import de.eshg.statistics.persistence.repository.EvaluationRepository; +import java.math.BigDecimal; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.hibernate.Hibernate; import org.springframework.data.domain.Page; @@ -89,25 +92,31 @@ public class EvaluationCopyService { copy.setDataSensitivity(original.getDataSensitivity()); copy.setName(cloneEvaluationRequest.clonedEvaluationName()); copy.setNumberOfTableRows(original.getNumberOfTableRows()); - copy.addTableColumns(copyTableColumns(original.getTableColumns())); + copy.addTableColumns(copyTableColumnsWithoutCellEntries(original.getTableColumns())); copy.addAnalyses(copyAnalyses(original.getAnalyses())); return evaluationRepository.save(copy).getExternalId(); } - private List<TableColumn> copyTableColumns(List<TableColumn> tableColumns) { + private List<TableColumn> copyTableColumnsWithoutCellEntries(List<TableColumn> tableColumns) { return tableColumns.stream().map(this::copyTableColumnWithoutCellEntries).toList(); } private TableColumn copyTableColumnWithoutCellEntries(TableColumn original) { - TableColumn copy = copyTableColumnWithoutCellEntriesWithoutMinMaxValues(original); + TableColumn copy = copyTableColumnWithoutCellEntriesAndMinMaxValuesAndAnonymization(original); Optional.ofNullable(original.getMinMaxNullUnknownValues()) .map(this::copyMinMaxNullUnknownValues) .ifPresent(copy::setMinMaxNullUnknownValues); + if (original.getAnonymizationConfiguration() != null) { + AnonymizationConfiguration anonymizationConfiguration = new AnonymizationConfiguration(); + copy.setAnonymizationConfiguration(anonymizationConfiguration); + copyAnonymizationConfiguration( + anonymizationConfiguration, original.getAnonymizationConfiguration()); + } return copy; } - public static TableColumn copyTableColumnWithoutCellEntriesWithoutMinMaxValues( + public static TableColumn copyTableColumnWithoutCellEntriesAndMinMaxValuesAndAnonymization( TableColumn original) { TableColumn copy = new TableColumn(); copy.setBusinessModuleName(original.getBusinessModuleName()); @@ -116,7 +125,6 @@ public class EvaluationCopyService { copy.setBaseModuleAttributeCode(original.getBaseModuleAttributeCode()); copy.setBaseModuleAttributeName(original.getBaseModuleAttributeName()); copy.setValueType(original.getValueType()); - copy.setDataPrivacyCategory(original.getDataPrivacyCategory()); copy.setUnit(original.getUnit()); copy.setDataSourceName(original.getDataSourceName()); copy.setDataSourceId(original.getDataSourceId()); @@ -152,6 +160,31 @@ public class EvaluationCopyService { return copy; } + static void copyAnonymizationConfiguration( + AnonymizationConfiguration currentConfiguration, + AnonymizationConfiguration newConfiguration) { + currentConfiguration.setDataPrivacyCategory(newConfiguration.getDataPrivacyCategory()); + currentConfiguration.setIntervalCount(newConfiguration.getIntervalCount()); + currentConfiguration.setMinDecimalInclusive(newConfiguration.getMinDecimalInclusive()); + currentConfiguration.setMaxDecimalInclusive(newConfiguration.getMaxDecimalInclusive()); + currentConfiguration.setMinIntegerInclusive(newConfiguration.getMinIntegerInclusive()); + currentConfiguration.setMaxIntegerInclusive(newConfiguration.getMaxIntegerInclusive()); + + Set<BigDecimal> currentDecimalBorders = currentConfiguration.getDecimalBorders(); + Set<BigDecimal> newDecimalBorders = newConfiguration.getDecimalBorders(); + if (currentDecimalBorders.size() != newDecimalBorders.size() + || !currentDecimalBorders.containsAll(newDecimalBorders)) { + currentConfiguration.setDecimalBorders(newDecimalBorders); + } + + Set<Integer> currentIntegerBorders = currentConfiguration.getIntegerBorders(); + Set<Integer> integerBorders = newConfiguration.getIntegerBorders(); + if (currentIntegerBorders.size() != integerBorders.size() + || !currentIntegerBorders.containsAll(integerBorders)) { + currentConfiguration.setIntegerBorders(integerBorders); + } + } + private List<Analysis> copyAnalyses(List<Analysis> analyses) { return analyses.stream() .map( diff --git a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/EvaluationExecution.java b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/EvaluationExecution.java index 35287df4c1bf9cac45418f099c4d02be091d843c..d33814acff8d60337c1a4279e2cd7c4e3a170f4b 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/EvaluationExecution.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/EvaluationExecution.java @@ -8,6 +8,7 @@ package de.eshg.statistics.aggregation; import static de.eshg.statistics.persistence.entity.AggregationResultPendingState.TABLE_ROWS_REMOVAL; import de.eshg.lib.rest.oauth.client.commons.ModuleClientAuthenticator; +import de.eshg.statistics.diagramcreation.DiagramCreationService; import de.eshg.statistics.exception.IncompleteDeletionException; import de.eshg.statistics.persistence.entity.AggregationResultPendingState; import de.eshg.statistics.persistence.entity.AggregationResultState; diff --git a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportExecution.java b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportExecution.java index 20991969745055f16f5d223c55a7724e8299a361..caf2224e1a22484b22034fa3114e208d69961f95 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportExecution.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/ReportExecution.java @@ -8,6 +8,7 @@ package de.eshg.statistics.aggregation; import de.eshg.lib.rest.oauth.client.commons.ModuleClientAuthenticator; import de.eshg.statistics.api.AddDiagramRequest; import de.eshg.statistics.api.AnalysisDto; +import de.eshg.statistics.diagramcreation.DiagramCreationService; import de.eshg.statistics.persistence.entity.AggregationResultPendingState; import de.eshg.statistics.persistence.entity.AggregationResultState; import java.util.Map; @@ -38,7 +39,9 @@ public class ReportExecution { } @Scheduled(cron = "${de.eshg.statistics.auto-report.schedule:@hourly}") - @SchedulerLock(name = "HandlePlannedReports") + @SchedulerLock( + name = "HandlePlannedReports", + lockAtMostFor = "${de.eshg.statistics.auto-report.lock-at-most-for:1h}") public void handlePlannedReports() { LockAssert.assertLocked(); log.info("Starting job 'HandlePlannedReports'"); 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 d805b1f2b6a704fa78a77437e87ab84dde303d66..f975b5203739510ea1cda276084b9c71043b6185 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 @@ -125,7 +125,9 @@ public class ReportService extends AbstractAggregationResultService { report.addTableColumns( evaluation.getTableColumns().stream() - .map(EvaluationCopyService::copyTableColumnWithoutCellEntriesWithoutMinMaxValues) + .map( + EvaluationCopyService + ::copyTableColumnWithoutCellEntriesAndMinMaxValuesAndAnonymization) .toList()); return report; } diff --git a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/TableRowSpecifications.java b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/TableRowSpecifications.java index 4b59726d775991146228f714ec7a694afda406a2..3e07bad619b876da3091641f74479657d4c3ba37 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/TableRowSpecifications.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/aggregation/TableRowSpecifications.java @@ -67,7 +67,7 @@ public class TableRowSpecifications { }; } - static Specification<TableRow> tableRowOfAggregationOrderByTableRowId( + public static Specification<TableRow> tableRowOfAggregationOrderByTableRowId( AbstractAggregationResult aggregationResult) { return (root, query, criteriaBuilder) -> { query.orderBy(criteriaBuilder.asc(root.get(BaseEntity_.ID))); @@ -86,7 +86,7 @@ public class TableRowSpecifications { }; } - static Specification<TableRow> createFilterSpecification( + public static Specification<TableRow> createFilterSpecification( TableColumnFilterParameter filter, AbstractAggregationResult aggregationResult) { TableColumn tableColumn = AggregationResultUtil.getTableColumn(filter.attribute(), aggregationResult); @@ -324,7 +324,7 @@ public class TableRowSpecifications { }; } - static Specification<TableRow> getValueOptionFilterSpecification( + public static Specification<TableRow> getValueOptionFilterSpecification( TableColumn tableColumn, List<String> valuesList, boolean searchForNull) { Set<String> values = new HashSet<>(valuesList); return (root, query, criteriaBuilder) -> { @@ -343,7 +343,7 @@ public class TableRowSpecifications { }; } - static Specification<TableRow> getNotNullAndNotUnknownSpecificationDecimalAndInteger( + public static Specification<TableRow> getNotNullAndNotUnknownSpecificationDecimalAndInteger( TableColumn tableColumn) { return switch (tableColumn.getValueType()) { case DECIMAL -> getNotNullAndNotUnknownSpecificationDecimal(tableColumn); @@ -389,7 +389,7 @@ public class TableRowSpecifications { }; } - static Specification<TableRow> getNotNullSpecification(TableColumn tableColumn) { + public static Specification<TableRow> getNotNullSpecification(TableColumn tableColumn) { String cellEntryValueColumn = getCellEntryValueColumn(tableColumn); return (root, query, criteriaBuilder) -> { Join<Object, Object> join = root.join(TableRow_.CELL_ENTRIES); diff --git a/backend/statistics/src/main/java/de/eshg/statistics/anonymization/AnonymizationService.java b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/AnonymizationService.java new file mode 100644 index 0000000000000000000000000000000000000000..90189b77591a024d29dc77d5ee0ccdaaf9c219e6 --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/AnonymizationService.java @@ -0,0 +1,256 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.anonymization; + +import de.eshg.statistics.aggregation.EvaluationService; +import de.eshg.statistics.anonymization.interval.DecimalIntervalConfiguration; +import de.eshg.statistics.anonymization.interval.DecimalIntervalUtil; +import de.eshg.statistics.anonymization.interval.IntegerIntervalConfiguration; +import de.eshg.statistics.anonymization.interval.IntegerIntervalUtil; +import de.eshg.statistics.anonymization.interval.Interval; +import de.eshg.statistics.persistence.entity.AnonymizationConfiguration; +import de.eshg.statistics.persistence.entity.CellEntry; +import de.eshg.statistics.persistence.entity.Evaluation; +import de.eshg.statistics.persistence.entity.MinMaxNullUnknownValues; +import de.eshg.statistics.persistence.entity.TableColumn; +import de.eshg.statistics.persistence.entity.TableColumnDataPrivacyCategory; +import de.eshg.statistics.persistence.entity.TableRow; +import de.eshg.statistics.persistence.entity.entry.DecimalEntry; +import de.eshg.statistics.persistence.entity.entry.IntegerEntry; +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Stream; +import org.deidentifier.arx.ARXConfiguration; +import org.deidentifier.arx.AttributeType; +import org.deidentifier.arx.Data; +import org.deidentifier.arx.aggregates.HierarchyBuilderRedactionBased; +import org.deidentifier.arx.criteria.DistinctLDiversity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AnonymizationService { + private static final String ROW_ID_COLUMN = "id"; + private static final String NULL_NUMBER_VALUE_FOR_DATA = "NULL"; + + private final EvaluationService evaluationService; + + public AnonymizationService(EvaluationService evaluationService) { + this.evaluationService = evaluationService; + } + + @Transactional + public Map<String, Interval<Number>> prepareAnonymization( + UUID evaluationId, ARXConfiguration config, Data.DefaultData data) { + Evaluation evaluation = evaluationService.getEvaluationInternal(evaluationId); + List<TableColumn> tableColumns = evaluation.getTableColumns(); + + data.add( + Stream.concat( + Stream.of(ROW_ID_COLUMN), tableColumns.stream().map(TableColumn::getSearchKey)) + .toArray(String[]::new)); + data.getDefinition().setAttributeType(ROW_ID_COLUMN, AttributeType.INSENSITIVE_ATTRIBUTE); + + // Todo DistinctLDiversity should be configured on the column? + tableColumns.stream() + .filter( + tableColumn -> + TableColumnDataPrivacyCategory.SENSITIVE.equals( + getTableColumnDataPrivacyCategory(tableColumn))) + .forEach( + tableColumn -> + config.addPrivacyModel(new DistinctLDiversity(tableColumn.getSearchKey(), 2))); + + Map<String, Interval<Number>> tableColumnSearchKeyToMaxInterval = new HashMap<>(); + tableColumns.forEach( + tableColumn -> + configureColumn(tableColumn, data) + .ifPresent( + minMaxInterval -> + tableColumnSearchKeyToMaxInterval.put( + tableColumn.getSearchKey(), minMaxInterval))); + + return tableColumnSearchKeyToMaxInterval; + } + + private static TableColumnDataPrivacyCategory getTableColumnDataPrivacyCategory( + TableColumn tableColumn) { + return tableColumn.getAnonymizationConfiguration() == null + ? null + : tableColumn.getAnonymizationConfiguration().getDataPrivacyCategory(); + } + + private static Optional<Interval<Number>> configureColumn( + TableColumn tableColumn, Data.DefaultData data) { + Optional<Interval<Number>> minMaxIntervalOptional = Optional.empty(); + TableColumnDataPrivacyCategory category = getTableColumnDataPrivacyCategory(tableColumn); + switch (category) { + // Todo errorhandling + case null -> throw new IllegalStateException("Not configured"); + case SENSITIVE -> + data.getDefinition() + .setAttributeType(tableColumn.getSearchKey(), AttributeType.SENSITIVE_ATTRIBUTE); + case INSENSITIVE -> + data.getDefinition() + .setAttributeType(tableColumn.getSearchKey(), AttributeType.INSENSITIVE_ATTRIBUTE); + case QUASI_IDENTIFYING -> + minMaxIntervalOptional = configureQuasiIdentifyingColumn(tableColumn, data); + } + return minMaxIntervalOptional; + } + + private static Optional<Interval<Number>> configureQuasiIdentifyingColumn( + TableColumn tableColumn, Data.DefaultData data) { + MinMaxNullUnknownValues minMaxNullUnknownValues = tableColumn.getMinMaxNullUnknownValues(); + AnonymizationConfiguration anonymizationConfiguration = + tableColumn.getAnonymizationConfiguration(); + + Interval<Number> minMaxInterval = null; + switch (tableColumn.getValueType()) { + case DECIMAL -> { + DecimalIntervalConfiguration intervalConfiguration = + DecimalIntervalUtil.createIntervalConfiguration(anonymizationConfiguration); + if (intervalConfiguration != null) { + minMaxInterval = + configureDecimalColumn( + tableColumn, data, minMaxNullUnknownValues, intervalConfiguration); + } else { + // Todo errorhandling + throw new IllegalStateException("Not configured decimal"); + } + } + case INTEGER -> { + IntegerIntervalConfiguration intervalConfiguration = + IntegerIntervalUtil.createIntervalConfiguration(anonymizationConfiguration); + if (intervalConfiguration != null) { + minMaxInterval = + configureIntegerColumn( + tableColumn, data, minMaxNullUnknownValues, intervalConfiguration); + } else { + // Todo errorhandling + throw new IllegalStateException("Not configured integer"); + } + } + case BOOLEAN, DATE, TEXT, VALUE_WITH_OPTIONS -> configureTextColumn(tableColumn, data); + case PROCEDURE_REFERENCE -> + throw new IllegalStateException("Procedure reference should be insensitive"); + } + + return minMaxInterval == null ? Optional.empty() : Optional.of(minMaxInterval); + } + + private static Interval<Number> configureDecimalColumn( + TableColumn tableColumn, + Data.DefaultData data, + MinMaxNullUnknownValues minMaxNullUnknownValues, + DecimalIntervalConfiguration intervalConfiguration) { + Optional<Interval<Number>> minMaxIntervalOptional = + DecimalIntervalUtil.configureColumn( + data, + tableColumn.getSearchKey(), + minMaxNullUnknownValues == null ? null : minMaxNullUnknownValues.getMinDecimal(), + minMaxNullUnknownValues == null ? null : minMaxNullUnknownValues.getMaxDecimal(), + intervalConfiguration); + return minMaxIntervalOptional.orElse(null); + } + + private static Interval<Number> configureIntegerColumn( + TableColumn tableColumn, + Data.DefaultData data, + MinMaxNullUnknownValues minMaxNullUnknownValues, + IntegerIntervalConfiguration intervalConfiguration) { + Optional<Interval<Number>> minMaxIntervalOptional = + IntegerIntervalUtil.configureColumn( + data, + tableColumn.getSearchKey(), + minMaxNullUnknownValues == null ? null : minMaxNullUnknownValues.getMinInteger(), + minMaxNullUnknownValues == null ? null : minMaxNullUnknownValues.getMaxInteger(), + intervalConfiguration); + return minMaxIntervalOptional.orElse(null); + } + + private static void configureTextColumn(TableColumn tableColumn, Data.DefaultData data) { + HierarchyBuilderRedactionBased<?> builder = + HierarchyBuilderRedactionBased.create( + HierarchyBuilderRedactionBased.Order.RIGHT_TO_LEFT, + HierarchyBuilderRedactionBased.Order.RIGHT_TO_LEFT, + ' ', + '*'); + data.getDefinition().setAttributeType(tableColumn.getSearchKey(), builder); + } + + @Transactional + public boolean addTableRows( + UUID evaluationId, + int page, + Data.DefaultData data, + Map<String, Interval<Number>> tableColumnSearchKeyToMaxInterval) { + Evaluation evaluationInternal = evaluationService.getEvaluationInternal(evaluationId); + + List<TableRow> tableRows = + evaluationService.getTableRowPage(evaluationInternal, page).getContent(); + + tableRows.forEach( + tableRow -> + data.add( + Stream.concat( + Stream.of(String.valueOf(tableRow.getId())), + tableRow.getCellEntries().stream() + .map( + cellEntry -> + mapCellEntryValue( + cellEntry, + tableColumnSearchKeyToMaxInterval.get( + cellEntry.getTableColumn().getSearchKey())))) + .toArray(String[]::new))); + + return tableRows.isEmpty(); + } + + private String mapCellEntryValue(CellEntry cellEntry, Interval<Number> numberIntervalOfColumn) { + return switch (cellEntry.getTableColumn().getValueType()) { + case DECIMAL -> + getDecimalValueInInterval( + ((DecimalEntry) cellEntry).getBigDecimalValue(), numberIntervalOfColumn); + case INTEGER -> + getIntegerValueInInterval( + ((IntegerEntry) cellEntry).getIntegerValue(), numberIntervalOfColumn); + case BOOLEAN, DATE, PROCEDURE_REFERENCE, TEXT, VALUE_WITH_OPTIONS -> + cellEntry.getValue() == null ? "" : cellEntry.getValue().toString(); + }; + } + + /* + * Values outside the interval have to be removed + */ + private String getDecimalValueInInterval( + BigDecimal value, Interval<Number> numberIntervalOfColumn) { + if (value == null + || numberIntervalOfColumn == null + || value.compareTo((BigDecimal) numberIntervalOfColumn.minInclusive()) < 0 + || value.compareTo((BigDecimal) numberIntervalOfColumn.maxExclusive()) > 0) { + return NULL_NUMBER_VALUE_FOR_DATA; + } + return value.toPlainString(); + } + + /* + * Values outside the interval have to be removed + */ + private String getIntegerValueInInterval(Integer value, Interval<Number> numberIntervalOfColumn) { + if (value == null + || numberIntervalOfColumn == null + || value < numberIntervalOfColumn.minInclusive().intValue() + || value > numberIntervalOfColumn.maxExclusive().intValue()) { + return NULL_NUMBER_VALUE_FOR_DATA; + } + return String.valueOf(value); + } +} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/CountIntervalConfiguration.java b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/CountIntervalConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..98b8345143e237d12664471b9ce4e69a829c322d --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/CountIntervalConfiguration.java @@ -0,0 +1,9 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.anonymization.interval; + +public record CountIntervalConfiguration(int countIntervals) + implements IntegerIntervalConfiguration, DecimalIntervalConfiguration {} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/DecimalIntervalBordersConfiguration.java b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/DecimalIntervalBordersConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..32de8a4947ab8adc3b70cd7370d5f7f2c2b6841f --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/DecimalIntervalBordersConfiguration.java @@ -0,0 +1,12 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.anonymization.interval; + +import java.math.BigDecimal; +import java.util.List; + +public record DecimalIntervalBordersConfiguration(List<BigDecimal> intervalBorders) + implements DecimalIntervalConfiguration {} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/DecimalIntervalConfiguration.java b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/DecimalIntervalConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..c262b9a6705a6a5a6f23d3202a0ff0d097aa100b --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/DecimalIntervalConfiguration.java @@ -0,0 +1,11 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.anonymization.interval; + +public sealed interface DecimalIntervalConfiguration + permits CountIntervalConfiguration, + DecimalMinMaxCountIntervalConfiguration, + DecimalIntervalBordersConfiguration {} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/DecimalIntervalUtil.java b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/DecimalIntervalUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..678bdcaeffd95eb43d8a9b1be84395d80ad751eb --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/DecimalIntervalUtil.java @@ -0,0 +1,150 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.anonymization.interval; + +import de.eshg.statistics.persistence.entity.AnonymizationConfiguration; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import org.deidentifier.arx.Data; +import org.deidentifier.arx.DataType; +import org.deidentifier.arx.aggregates.HierarchyBuilderIntervalBased; + +public class DecimalIntervalUtil { + private static final BigDecimal MINIMAL_DIFFERENCE = BigDecimal.valueOf(0.0001); + + private DecimalIntervalUtil() {} + + public static DecimalIntervalConfiguration createIntervalConfiguration( + AnonymizationConfiguration anonymizationConfiguration) { + if (anonymizationConfiguration == null) { + return null; + } + if (anonymizationConfiguration.getIntervalCount() == null) { + Set<BigDecimal> borders = anonymizationConfiguration.getDecimalBorders(); + if (borders.isEmpty()) { + return null; + } else { + return new DecimalIntervalBordersConfiguration(borders.stream().toList()); + } + } else { + if (anonymizationConfiguration.getMinDecimalInclusive() == null + || anonymizationConfiguration.getMaxDecimalInclusive() == null) { + return new CountIntervalConfiguration(anonymizationConfiguration.getIntervalCount()); + } else { + return new DecimalMinMaxCountIntervalConfiguration( + anonymizationConfiguration.getMinDecimalInclusive(), + anonymizationConfiguration.getMaxDecimalInclusive(), + anonymizationConfiguration.getIntervalCount()); + } + } + } + + public static Optional<Interval<Number>> configureColumn( + Data.DefaultData data, + String column, + BigDecimal minDecimal, + BigDecimal maxDecimal, + DecimalIntervalConfiguration intervalConfiguration) { + List<Interval<BigDecimal>> intervalList = + switch (intervalConfiguration) { + case CountIntervalConfiguration(int countIntervals) -> { + if (minDecimal != null && maxDecimal != null) { + yield createIntervals(minDecimal, maxDecimal, countIntervals); + } else { + yield Collections.emptyList(); + } + } + case DecimalIntervalBordersConfiguration(List<BigDecimal> intervalBorders) -> + createIntervals(intervalBorders); + case DecimalMinMaxCountIntervalConfiguration( + BigDecimal minInclusive, + BigDecimal maxInclusive, + int countIntervals) -> + createIntervals(minInclusive, maxInclusive, countIntervals); + }; + + HierarchyBuilderIntervalBased<Double> builder = createIntervalBasedBuilder(intervalList); + + data.getDefinition().setAttributeType(column, builder); + if (intervalList.isEmpty()) { + return Optional.empty(); + } else { + return Optional.of( + new Interval<>( + intervalList.getFirst().minInclusive(), intervalList.getLast().maxExclusive())); + } + } + + private static List<Interval<BigDecimal>> createIntervals( + BigDecimal minInclusive, BigDecimal maxInclusive, int countIntervals) { + BigDecimal intervalSize = + maxInclusive + .add(MINIMAL_DIFFERENCE) + .subtract(minInclusive) + .divide(BigDecimal.valueOf(countIntervals), 4, RoundingMode.HALF_UP); + + if (intervalSize.compareTo(BigDecimal.ZERO) == 0) { + return Collections.emptyList(); + } + + List<Interval<BigDecimal>> intervals = new ArrayList<>(); + BigDecimal lowerBound = minInclusive; + for (int i = 1; i < countIntervals; i++) { + BigDecimal upperBoundExclusive = round(lowerBound.add(intervalSize)); + intervals.add(new Interval<>(lowerBound, upperBoundExclusive)); + lowerBound = upperBoundExclusive; + } + intervals.add(new Interval<>(lowerBound, round(maxInclusive.add(MINIMAL_DIFFERENCE)))); + + return intervals; + } + + private static List<Interval<BigDecimal>> createIntervals(List<BigDecimal> borders) { + if (borders.size() < 2) { + return Collections.emptyList(); + } + List<Interval<BigDecimal>> intervals = new ArrayList<>(); + for (int i = 0; i < borders.size() - 2; i++) { + intervals.add(new Interval<>(borders.get(i), borders.get(i + 1))); + } + intervals.add( + new Interval<>( + borders.get(borders.size() - 2), round(borders.getLast().add(MINIMAL_DIFFERENCE)))); + + return intervals; + } + + private static BigDecimal round(BigDecimal decimal) { + return decimal.setScale(4, RoundingMode.HALF_UP); + } + + private static HierarchyBuilderIntervalBased<Double> createIntervalBasedBuilder( + List<Interval<BigDecimal>> intervalList) { + DataType<Double> dataType = DataType.createDecimal("#.####", Locale.ENGLISH); + HierarchyBuilderIntervalBased<Double> builder = HierarchyBuilderIntervalBased.create(dataType); + builder.setAggregateFunction(dataType.createAggregate().createIntervalFunction(true, false)); + + intervalList.forEach( + interval -> + builder.addInterval( + interval.minInclusive().doubleValue(), + interval.maxExclusive().doubleValue(), + "[" + + interval.minInclusive().stripTrailingZeros().toPlainString() + + "," + + round(interval.maxExclusive().subtract(MINIMAL_DIFFERENCE)) + .stripTrailingZeros() + .toPlainString() + + "]")); + return builder; + } +} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/DecimalMinMaxCountIntervalConfiguration.java b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/DecimalMinMaxCountIntervalConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..18350b0d5b79d915aab4a785f81611881019a193 --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/DecimalMinMaxCountIntervalConfiguration.java @@ -0,0 +1,12 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.anonymization.interval; + +import java.math.BigDecimal; + +public record DecimalMinMaxCountIntervalConfiguration( + BigDecimal minInclusive, BigDecimal maxInclusive, int countIntervals) + implements DecimalIntervalConfiguration {} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/IntegerIntervalBordersConfiguration.java b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/IntegerIntervalBordersConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..00e678808d33126101353b5a16a8924b6f4ffdfe --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/IntegerIntervalBordersConfiguration.java @@ -0,0 +1,11 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.anonymization.interval; + +import java.util.List; + +public record IntegerIntervalBordersConfiguration(List<Integer> intervalBorders) + implements IntegerIntervalConfiguration {} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/IntegerIntervalConfiguration.java b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/IntegerIntervalConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..0921be436bb9c8a38acd804b5731a35d9c9adbe1 --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/IntegerIntervalConfiguration.java @@ -0,0 +1,11 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.anonymization.interval; + +public sealed interface IntegerIntervalConfiguration + permits CountIntervalConfiguration, + IntegerMinMaxCountIntervalConfiguration, + IntegerIntervalBordersConfiguration {} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/IntegerIntervalUtil.java b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/IntegerIntervalUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..c6ad80596773ec1a0764a4bdadc4417a03c1f390 --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/IntegerIntervalUtil.java @@ -0,0 +1,137 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.anonymization.interval; + +import de.eshg.statistics.persistence.entity.AnonymizationConfiguration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.deidentifier.arx.Data; +import org.deidentifier.arx.DataType; +import org.deidentifier.arx.aggregates.HierarchyBuilderIntervalBased; + +public class IntegerIntervalUtil { + + private static final long MINIMAL_DIFFERENCE = 1L; + + private IntegerIntervalUtil() {} + + public static IntegerIntervalConfiguration createIntervalConfiguration( + AnonymizationConfiguration anonymizationConfiguration) { + if (anonymizationConfiguration == null) { + return null; + } + if (anonymizationConfiguration.getIntervalCount() == null) { + Set<Integer> borders = anonymizationConfiguration.getIntegerBorders(); + if (borders.isEmpty()) { + return null; + } else { + return new IntegerIntervalBordersConfiguration(borders.stream().toList()); + } + } else { + if (anonymizationConfiguration.getMinIntegerInclusive() == null + || anonymizationConfiguration.getMaxIntegerInclusive() == null) { + return new CountIntervalConfiguration(anonymizationConfiguration.getIntervalCount()); + } else { + return new IntegerMinMaxCountIntervalConfiguration( + anonymizationConfiguration.getMinIntegerInclusive(), + anonymizationConfiguration.getMaxIntegerInclusive(), + anonymizationConfiguration.getIntervalCount()); + } + } + } + + public static Optional<Interval<Number>> configureColumn( + Data.DefaultData data, + String column, + Integer minInteger, + Integer maxInteger, + IntegerIntervalConfiguration intervalConfiguration) { + List<Interval<Long>> intervalList = + switch (intervalConfiguration) { + case CountIntervalConfiguration(int countIntervals) -> { + if (minInteger != null && maxInteger != null) { + yield createIntervals(minInteger, maxInteger, countIntervals); + } else { + yield Collections.emptyList(); + } + } + case IntegerIntervalBordersConfiguration(List<Integer> intervalBorders) -> + createIntervals(intervalBorders); + case IntegerMinMaxCountIntervalConfiguration( + int minInclusive, + int maxInclusive, + int countIntervals) -> + createIntervals(minInclusive, maxInclusive, countIntervals); + }; + + HierarchyBuilderIntervalBased<Long> builder = createIntervalBasedBuilder(intervalList); + + data.getDefinition().setAttributeType(column, builder); + if (intervalList.isEmpty()) { + return Optional.empty(); + } else { + return Optional.of( + new Interval<>( + intervalList.getFirst().minInclusive(), intervalList.getLast().maxExclusive())); + } + } + + private static List<Interval<Long>> createIntervals( + int minInclusive, int maxInclusive, int countIntervals) { + long intervalSize = (maxInclusive + MINIMAL_DIFFERENCE - minInclusive) / countIntervals; + if (intervalSize == 0) { + return Collections.emptyList(); + } + List<Interval<Long>> intervals = new ArrayList<>(); + long lowerBound = minInclusive; + for (int i = 1; i < countIntervals; i++) { + long upperBoundExclusive = lowerBound + intervalSize; + intervals.add(new Interval<>(lowerBound, upperBoundExclusive)); + lowerBound = upperBoundExclusive; + } + intervals.add(new Interval<>(lowerBound, maxInclusive + MINIMAL_DIFFERENCE)); + + return intervals; + } + + private static List<Interval<Long>> createIntervals(List<Integer> borders) { + if (borders.size() < 2) { + return Collections.emptyList(); + } + List<Interval<Long>> intervals = new ArrayList<>(); + for (int i = 0; i < borders.size() - 2; i++) { + intervals.add(new Interval<>((long) borders.get(i), (long) borders.get(i + 1))); + } + intervals.add( + new Interval<>( + (long) borders.get(borders.size() - 2), (long) borders.getLast() + MINIMAL_DIFFERENCE)); + + return intervals; + } + + private static HierarchyBuilderIntervalBased<Long> createIntervalBasedBuilder( + List<Interval<Long>> intervalList) { + HierarchyBuilderIntervalBased<Long> builder = + HierarchyBuilderIntervalBased.create(DataType.INTEGER); + builder.setAggregateFunction( + DataType.INTEGER.createAggregate().createIntervalFunction(true, false)); + + intervalList.forEach( + interval -> + builder.addInterval( + interval.minInclusive(), + interval.maxExclusive(), + "[" + + interval.minInclusive() + + "," + + (interval.maxExclusive() - MINIMAL_DIFFERENCE) + + "]")); + return builder; + } +} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/IntegerMinMaxCountIntervalConfiguration.java b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/IntegerMinMaxCountIntervalConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..7204151144ba65a24cabd7cbab2d336d4839942f --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/IntegerMinMaxCountIntervalConfiguration.java @@ -0,0 +1,10 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.anonymization.interval; + +public record IntegerMinMaxCountIntervalConfiguration( + int minInclusive, int maxInclusive, int countIntervals) + implements IntegerIntervalConfiguration {} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/Interval.java b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/Interval.java new file mode 100644 index 0000000000000000000000000000000000000000..6bffeb800df82ecf392fa3e430c94172a2f12d77 --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/anonymization/interval/Interval.java @@ -0,0 +1,8 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.anonymization.interval; + +public record Interval<T extends Number>(T minInclusive, T maxExclusive) {} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/AbstractChartDiagramCreationService.java b/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/AbstractChartDiagramCreationService.java new file mode 100644 index 0000000000000000000000000000000000000000..44484e31f9bf9bd7de4c0554366280614560ecaa --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/AbstractChartDiagramCreationService.java @@ -0,0 +1,207 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.diagramcreation; + +import de.eshg.statistics.aggregation.AnalysisService; +import de.eshg.statistics.aggregation.TableRowSpecifications; +import de.eshg.statistics.api.AddDiagramRequest; +import de.eshg.statistics.api.filter.TableColumnFilterParameter; +import de.eshg.statistics.config.StatisticsConfig; +import de.eshg.statistics.persistence.entity.AbstractAggregationResult; +import de.eshg.statistics.persistence.entity.CellEntry; +import de.eshg.statistics.persistence.entity.TableColumn; +import de.eshg.statistics.persistence.entity.TableColumnValueType; +import de.eshg.statistics.persistence.entity.TableRow; +import de.eshg.statistics.persistence.entity.ValueToMeaning; +import de.eshg.statistics.persistence.entity.diagramdata.KeyToCount; +import de.eshg.statistics.persistence.entity.entry.BooleanEntry; +import de.eshg.statistics.persistence.entity.entry.DecimalEntry; +import de.eshg.statistics.persistence.entity.entry.IntegerEntry; +import de.eshg.statistics.persistence.repository.AnalysisRepository; +import de.eshg.statistics.persistence.repository.TableRowRepository; +import java.math.BigDecimal; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.util.CollectionUtils; + +public abstract class AbstractChartDiagramCreationService<D, C> { + protected final AnalysisService analysisService; + protected final AnalysisRepository analysisRepository; + + private final TableRowRepository tableRowRepository; + private final int pageSizeForCollectionDiagramData; + + protected AbstractChartDiagramCreationService( + AnalysisService analysisService, + AnalysisRepository analysisRepository, + TableRowRepository tableRowRepository, + StatisticsConfig statisticsConfig) { + this.analysisService = analysisService; + this.analysisRepository = analysisRepository; + this.tableRowRepository = tableRowRepository; + this.pageSizeForCollectionDiagramData = statisticsConfig.diagramData().pageSize(); + } + + abstract D initializeChartDataHolder(); + + abstract int collectChartData( + UUID analysisId, + C chartConfigurationDto, + List<TableColumnFilterParameter> filters, + int page, + D chartDataHolder); + + abstract UUID addDiagram( + UUID analysisId, + C chartConfigurationDto, + AddDiagramRequest addDiagramRequest, + D chartDataHolder); + + protected static CellEntry getCellEntry(TableRow tableRow, TableColumn tableColumn) { + return tableRow.getCellEntries().stream() + .filter(cellEntry -> cellEntry.getTableColumn().getId().equals(tableColumn.getId())) + .findFirst() + .orElseThrow(); + } + + protected int collectDataForTablePageAndReturnMaxPage( + int page, + Stream<Specification<TableRow>> attributeSpecificationStream, + List<TableColumnFilterParameter> filters, + AbstractAggregationResult aggregationResult, + Consumer<TableRow> tableRowDataCollector) { + + Stream<Specification<TableRow>> attributePlusFilters = + Stream.concat( + attributeSpecificationStream, getFilterSpecificationStream(filters, aggregationResult)); + + Specification<TableRow> specification = + Specification.allOf( + Stream.concat( + Stream.of( + TableRowSpecifications.tableRowOfAggregationOrderByTableRowId( + aggregationResult)), + attributePlusFilters) + .toList()); + + Page<TableRow> tableRowPage = + tableRowRepository.findAll( + specification, PageRequest.of(page, pageSizeForCollectionDiagramData)); + + tableRowPage.get().forEach(tableRowDataCollector); + + long totalElements = tableRowPage.getTotalElements(); + if (totalElements % pageSizeForCollectionDiagramData == 0) { + return ((int) totalElements / pageSizeForCollectionDiagramData) - 1; + } else { + return (int) totalElements / pageSizeForCollectionDiagramData; + } + } + + private static Stream<Specification<TableRow>> getFilterSpecificationStream( + List<TableColumnFilterParameter> filters, AbstractAggregationResult aggregationResult) { + if (CollectionUtils.isEmpty(filters)) { + return Stream.empty(); + } + return filters.stream() + .map(filter -> TableRowSpecifications.createFilterSpecification(filter, aggregationResult)); + } + + protected static String getKeyForCellEntryBooleanTextOrValueOption(CellEntry cellEntry) { + if (cellEntry.getValue() == null) { + return null; + } + if (cellEntry.getTableColumn().getValueType().equals(TableColumnValueType.BOOLEAN)) { + return Boolean.TRUE.equals(cellEntry.getValue()) ? "Ja" : "Nein"; + } + if (cellEntry.getTableColumn().getValueType().equals(TableColumnValueType.TEXT)) { + return cellEntry.getValue().toString(); + } + String stringValue = cellEntry.getValue().toString(); + if (cellEntry.getTableColumn().getValueType().equals(TableColumnValueType.VALUE_WITH_OPTIONS) + && getValueToMeaningKeys(cellEntry.getTableColumn()).contains(stringValue)) { + return stringValue; + } + return null; + } + + protected static Set<String> getValueToMeaningKeys(TableColumn tableColumn) { + return tableColumn.getValueToMeanings().stream() + .map(ValueToMeaning::getValue) + .collect(Collectors.toSet()); + } + + protected static <T> void addTableRowToCollectedChartData( + T primaryKey, String secondaryKey, Map<T, Map<String, Integer>> collectedChartData) { + if (primaryKey == null || secondaryKey == null) { + return; + } + + Map<String, Integer> secondaryToIntegerMap = + collectedChartData.computeIfAbsent(primaryKey, key -> new HashMap<>()); + secondaryToIntegerMap.compute(secondaryKey, (key, count) -> (count == null) ? 1 : count + 1); + } + + protected static BigDecimal getValueAsBigDecimal( + TableColumnValueType valueType, CellEntry cellEntry) { + return switch (valueType) { + case TableColumnValueType.BOOLEAN -> + Boolean.TRUE.equals(((BooleanEntry) cellEntry).getBoolValue()) + ? BigDecimal.ONE + : BigDecimal.ZERO; + case TableColumnValueType.DECIMAL -> ((DecimalEntry) cellEntry).getBigDecimalValue(); + case TableColumnValueType.INTEGER -> + new BigDecimal(((IntegerEntry) cellEntry).getIntegerValue()); + default -> throw new IllegalStateException("Unexpected value: " + valueType); + }; + } + + protected static <T> Set<String> getKeysForTextValues(Map<T, Map<String, Integer>> valueMap) { + Set<String> keys = new HashSet<>(); + valueMap.values().forEach(map -> keys.addAll(map.keySet())); + return keys; + } + + protected static Set<String> getKeysForBooleanOrValueOption(TableColumn tableColumn) { + if (tableColumn == null) { + return Collections.emptySet(); + } + if (tableColumn.getValueType().equals(TableColumnValueType.BOOLEAN)) { + return Set.of("Ja", "Nein"); + } + if (tableColumn.getValueType().equals(TableColumnValueType.VALUE_WITH_OPTIONS)) { + return getValueToMeaningKeys(tableColumn); + } + return Collections.emptySet(); + } + + protected static List<KeyToCount> mapToSortedKeyToCountList( + Map<String, Integer> keyToCountStringIntegerMap) { + return keyToCountStringIntegerMap.entrySet().stream() + .map(AbstractChartDiagramCreationService::getKeyToCount) + .sorted(Comparator.comparing(KeyToCount::getKey)) + .toList(); + } + + private static KeyToCount getKeyToCount(Map.Entry<String, Integer> entry) { + KeyToCount keyToCount = new KeyToCount(); + keyToCount.setKey(entry.getKey()); + keyToCount.setCount(entry.getValue()); + return keyToCount; + } +} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/BarChartDiagramCreationService.java b/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/BarChartDiagramCreationService.java new file mode 100644 index 0000000000000000000000000000000000000000..573e97e1ee8d6cc6035eefe2118fd5423c2de1f5 --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/BarChartDiagramCreationService.java @@ -0,0 +1,210 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.diagramcreation; + +import de.eshg.statistics.aggregation.AggregationResultUtil; +import de.eshg.statistics.aggregation.AnalysisService; +import de.eshg.statistics.aggregation.TableRowSpecifications; +import de.eshg.statistics.api.AddDiagramRequest; +import de.eshg.statistics.api.chart.BarChartConfigurationDto; +import de.eshg.statistics.api.filter.TableColumnFilterParameter; +import de.eshg.statistics.config.StatisticsConfig; +import de.eshg.statistics.mapper.AnalysisMapper; +import de.eshg.statistics.persistence.entity.AbstractAggregationResult; +import de.eshg.statistics.persistence.entity.Analysis; +import de.eshg.statistics.persistence.entity.Diagram; +import de.eshg.statistics.persistence.entity.TableColumn; +import de.eshg.statistics.persistence.entity.TableColumnValueType; +import de.eshg.statistics.persistence.entity.TableRow; +import de.eshg.statistics.persistence.entity.diagramdata.BarChartData; +import de.eshg.statistics.persistence.entity.diagramdata.BarGroupData; +import de.eshg.statistics.persistence.entity.diagramdata.KeyToCount; +import de.eshg.statistics.persistence.repository.AnalysisRepository; +import de.eshg.statistics.persistence.repository.TableRowRepository; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class BarChartDiagramCreationService + extends AbstractChartDiagramCreationService< + Map<String, Map<String, Integer>>, BarChartConfigurationDto> { + public BarChartDiagramCreationService( + AnalysisService analysisService, + AnalysisRepository analysisRepository, + TableRowRepository tableRowRepository, + StatisticsConfig statisticsConfig) { + super(analysisService, analysisRepository, tableRowRepository, statisticsConfig); + } + + @Override + Map<String, Map<String, Integer>> initializeChartDataHolder() { + return new HashMap<>(); + } + + @Override + @Transactional(readOnly = true) + public int collectChartData( + UUID analysisId, + BarChartConfigurationDto barChartConfigurationDto, + List<TableColumnFilterParameter> filters, + int page, + Map<String, Map<String, Integer>> chartDataHolder) { + Analysis analysis = analysisService.getAnalysisInternal(analysisId); + AbstractAggregationResult aggregationResult = analysis.getAggregationResult(); + + TableColumn primaryTableColumn = + AggregationResultUtil.getTableColumn( + barChartConfigurationDto.primaryAttribute(), aggregationResult); + TableColumn secondaryTableColumn = + AggregationResultUtil.getTableColumn( + barChartConfigurationDto.secondaryAttribute(), aggregationResult); + if (page == 0) { + AggregationResultUtil.validateColumnFilters(filters, aggregationResult); + } + + Stream<Specification<TableRow>> notNullSpecifications; + if (secondaryTableColumn == null) { + notNullSpecifications = + Stream.of(TableRowSpecifications.getNotNullSpecification(primaryTableColumn)); + } else { + notNullSpecifications = + Stream.of( + TableRowSpecifications.getNotNullSpecification(primaryTableColumn), + TableRowSpecifications.getNotNullSpecification(secondaryTableColumn)); + } + + return collectDataForTablePageAndReturnMaxPage( + page, + notNullSpecifications, + filters, + aggregationResult, + tableRow -> + addTableRowToCollectedBarChartData( + tableRow, chartDataHolder, primaryTableColumn, secondaryTableColumn)); + } + + private static void addTableRowToCollectedBarChartData( + TableRow tableRow, + Map<String, Map<String, Integer>> chartDataHolder, + TableColumn primaryTableColumn, + TableColumn secondaryTableColumn) { + String primaryKey = + getKeyForCellEntryBooleanTextOrValueOption(getCellEntry(tableRow, primaryTableColumn)); + + String secondaryKey; + if (secondaryTableColumn == null) { + secondaryKey = primaryKey; + } else { + secondaryKey = + getKeyForCellEntryBooleanTextOrValueOption(getCellEntry(tableRow, secondaryTableColumn)); + } + + addTableRowToCollectedChartData(primaryKey, secondaryKey, chartDataHolder); + } + + @Override + @Transactional + public UUID addDiagram( + UUID analysisId, + BarChartConfigurationDto barChartConfigurationDto, + AddDiagramRequest addDiagramRequest, + Map<String, Map<String, Integer>> chartDataHolder) { + Analysis analysis = analysisService.getAnalysisInternal(analysisId); + fillBarChartDataWithMissingValues( + chartDataHolder, analysis.getAggregationResult(), barChartConfigurationDto); + + List<BarGroupData> groupDataList = getBarGroupDataList(chartDataHolder); + + int evaluatedEntries = + groupDataList.stream() + .map(BarGroupData::getKeyToCounts) + .flatMap(Collection::stream) + .mapToInt(KeyToCount::getCount) + .sum(); + + BarChartData barChartData = new BarChartData(); + barChartData.setEvaluatedDataAmount(evaluatedEntries); + barChartData.addBarGroupDatas(groupDataList); + + Diagram diagram = AnalysisMapper.mapToPersistence(addDiagramRequest, barChartData, analysis); + + analysisRepository.flush(); + return diagram.getExternalId(); + } + + private static void fillBarChartDataWithMissingValues( + Map<String, Map<String, Integer>> chartDataHolder, + AbstractAggregationResult aggregationResult, + BarChartConfigurationDto barChartConfigurationDto) { + TableColumn primaryTableColumn = + AggregationResultUtil.getTableColumn( + barChartConfigurationDto.primaryAttribute(), aggregationResult); + TableColumn secondaryTableColumn = + AggregationResultUtil.getTableColumn( + barChartConfigurationDto.secondaryAttribute(), aggregationResult); + + Set<String> primaryKeysBooleanValueOption = getKeysForBooleanOrValueOption(primaryTableColumn); + if (secondaryTableColumn == null) { + primaryKeysBooleanValueOption.forEach( + key -> + chartDataHolder.computeIfAbsent( + key, + secondaryKey -> { + Map<String, Integer> secondaryMap = new HashMap<>(); + secondaryMap.put(secondaryKey, 0); + return secondaryMap; + })); + } else { + Set<String> secondaryKeys; + if (secondaryTableColumn.getValueType().equals(TableColumnValueType.TEXT)) { + secondaryKeys = getKeysForTextValues(chartDataHolder); + } else { + secondaryKeys = getKeysForBooleanOrValueOption(secondaryTableColumn); + } + primaryKeysBooleanValueOption.forEach( + key -> chartDataHolder.computeIfAbsent(key, k -> new HashMap<>())); + + chartDataHolder + .keySet() + .forEach( + primaryKey -> { + Map<String, Integer> secondaryToIntegerMap = chartDataHolder.get(primaryKey); + secondaryKeys.forEach( + key -> secondaryToIntegerMap.computeIfAbsent(key, secondaryKey -> 0)); + }); + } + } + + private static List<BarGroupData> getBarGroupDataList( + Map<String, Map<String, Integer>> chartDataHolder) { + Map<String, BarGroupData> groupDataMap = + chartDataHolder.entrySet().stream() + .map(entry -> mapToBarGroupData(entry.getKey(), entry.getValue())) + .collect(Collectors.toMap(BarGroupData::getKey, Function.identity())); + + return groupDataMap.keySet().stream().sorted().map(groupDataMap::get).toList(); + } + + private static BarGroupData mapToBarGroupData( + String key, Map<String, Integer> keyToCountStringIntegerMap) { + List<KeyToCount> keyToCounts = mapToSortedKeyToCountList(keyToCountStringIntegerMap); + + BarGroupData barGroupData = new BarGroupData(); + barGroupData.setKey(key); + barGroupData.addKeyToCounts(keyToCounts); + return barGroupData; + } +} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/ChoroplethMapDiagramCreationService.java b/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/ChoroplethMapDiagramCreationService.java new file mode 100644 index 0000000000000000000000000000000000000000..013d516fe1d0ad135791e51de8f2ebaa3ed1ad5b --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/ChoroplethMapDiagramCreationService.java @@ -0,0 +1,207 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.diagramcreation; + +import de.eshg.statistics.GeoJsonHandler; +import de.eshg.statistics.aggregation.AggregationResultUtil; +import de.eshg.statistics.aggregation.AnalysisService; +import de.eshg.statistics.aggregation.TableRowSpecifications; +import de.eshg.statistics.api.AddDiagramRequest; +import de.eshg.statistics.api.chart.CalculationDto; +import de.eshg.statistics.api.chart.ChoroplethMapConfigurationDto; +import de.eshg.statistics.api.filter.TableColumnFilterParameter; +import de.eshg.statistics.config.StatisticsConfig; +import de.eshg.statistics.mapper.AnalysisMapper; +import de.eshg.statistics.persistence.entity.AbstractAggregationResult; +import de.eshg.statistics.persistence.entity.Analysis; +import de.eshg.statistics.persistence.entity.CellEntry; +import de.eshg.statistics.persistence.entity.Diagram; +import de.eshg.statistics.persistence.entity.TableColumn; +import de.eshg.statistics.persistence.entity.TableColumnValueType; +import de.eshg.statistics.persistence.entity.TableRow; +import de.eshg.statistics.persistence.entity.diagramdata.ChoroplethMapData; +import de.eshg.statistics.persistence.entity.diagramdata.KeyToValue; +import de.eshg.statistics.persistence.repository.AnalysisRepository; +import de.eshg.statistics.persistence.repository.TableRowRepository; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ChoroplethMapDiagramCreationService + extends AbstractChartDiagramCreationService< + Map<String, List<BigDecimal>>, ChoroplethMapConfigurationDto> { + public ChoroplethMapDiagramCreationService( + AnalysisService analysisService, + AnalysisRepository analysisRepository, + TableRowRepository tableRowRepository, + StatisticsConfig statisticsConfig) { + super(analysisService, analysisRepository, tableRowRepository, statisticsConfig); + } + + @Override + Map<String, List<BigDecimal>> initializeChartDataHolder() { + return new TreeMap<>(); + } + + @Override + @Transactional(readOnly = true) + public int collectChartData( + UUID analysisId, + ChoroplethMapConfigurationDto choroplethMapConfigurationDto, + List<TableColumnFilterParameter> filters, + int page, + Map<String, List<BigDecimal>> chartDataHolder) { + Analysis analysis = analysisService.getAnalysisInternal(analysisId); + AbstractAggregationResult aggregationResult = analysis.getAggregationResult(); + + TableColumn primaryTableColumn = + AggregationResultUtil.getTableColumn( + choroplethMapConfigurationDto.primaryAttribute(), aggregationResult); + TableColumn secondaryTableColumn = + AggregationResultUtil.getTableColumn( + choroplethMapConfigurationDto.secondaryAttribute(), aggregationResult); + List<String> geoKeys = GeoJsonHandler.getGeoKeys(choroplethMapConfigurationDto.geoJson()); + + if (page == 0) { + AggregationResultUtil.validateColumnFilters(filters, aggregationResult); + initializeChoroplethMapData(chartDataHolder, geoKeys); + } + + List<Specification<TableRow>> specifications = + getNotNullSpecificationsForChoroplethMap(primaryTableColumn, secondaryTableColumn); + + specifications.add( + TableRowSpecifications.getValueOptionFilterSpecification( + primaryTableColumn, geoKeys, false)); + + return collectDataForTablePageAndReturnMaxPage( + page, + specifications.stream(), + filters, + aggregationResult, + tableRow -> + addTableRowToCollectedChoroplethMapData( + tableRow, chartDataHolder, primaryTableColumn, secondaryTableColumn)); + } + + private static void initializeChoroplethMapData( + Map<String, List<BigDecimal>> chartDataHolder, List<String> geoKeys) { + geoKeys.forEach(geoKey -> chartDataHolder.computeIfAbsent(geoKey, key -> new ArrayList<>())); + } + + private static List<Specification<TableRow>> getNotNullSpecificationsForChoroplethMap( + TableColumn primaryTableColumn, TableColumn secondaryTableColumn) { + List<Specification<TableRow>> notNullSpecifications = new ArrayList<>(); + notNullSpecifications.add(TableRowSpecifications.getNotNullSpecification(primaryTableColumn)); + if (secondaryTableColumn != null) { + switch (secondaryTableColumn.getValueType()) { + case TableColumnValueType.BOOLEAN -> + notNullSpecifications.add( + TableRowSpecifications.getNotNullSpecification(secondaryTableColumn)); + case TableColumnValueType.DECIMAL, TableColumnValueType.INTEGER -> + notNullSpecifications.add( + TableRowSpecifications.getNotNullAndNotUnknownSpecificationDecimalAndInteger( + secondaryTableColumn)); + default -> + throw new IllegalStateException( + "Unexpected value type: " + secondaryTableColumn.getValueType()); + } + } + return notNullSpecifications; + } + + private static void addTableRowToCollectedChoroplethMapData( + TableRow tableRow, + Map<String, List<BigDecimal>> chartDataHolder, + TableColumn primaryTableColumn, + TableColumn secondaryTableColumn) { + String primaryKey = getKeyForTextOrValueOption(getCellEntry(tableRow, primaryTableColumn)); + + if (StringUtils.isBlank(primaryKey)) { + return; + } + BigDecimal value; + if (secondaryTableColumn == null) { + value = BigDecimal.ONE; + } else { + CellEntry cellEntry = getCellEntry(tableRow, secondaryTableColumn); + value = getValueAsBigDecimal(secondaryTableColumn.getValueType(), cellEntry); + } + + chartDataHolder.computeIfAbsent(primaryKey, key -> new ArrayList<>()).add(value); + } + + private static String getKeyForTextOrValueOption(CellEntry cellEntry) { + if (cellEntry.getValue() == null) { + return null; + } + + String stringValue = cellEntry.getValue().toString(); + return switch (cellEntry.getTableColumn().getValueType()) { + case TableColumnValueType.TEXT -> stringValue; + case TableColumnValueType.VALUE_WITH_OPTIONS -> { + if (getValueToMeaningKeys(cellEntry.getTableColumn()).contains(stringValue)) { + yield stringValue; + } else { + yield null; + } + } + default -> + throw new IllegalStateException( + "Unexpected value type: " + cellEntry.getTableColumn().getValueType()); + }; + } + + @Override + @Transactional + public UUID addDiagram( + UUID analysisId, + ChoroplethMapConfigurationDto choroplethMapConfigurationDto, + AddDiagramRequest addDiagramRequest, + Map<String, List<BigDecimal>> chartDataHolder) { + Analysis analysis = analysisService.getAnalysisInternal(analysisId); + + List<KeyToValue> keyToValues = new ArrayList<>(); + AtomicInteger evaluatedDataAmount = new AtomicInteger(0); + chartDataHolder.forEach( + (key, value) -> { + KeyToValue keyToValue = new KeyToValue(); + keyToValue.setKey(key); + BigDecimal sum = value.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + if (CalculationDto.MEAN.equals(choroplethMapConfigurationDto.calculation())) { + BigDecimal mean = + value.isEmpty() + ? null + : sum.divide(new BigDecimal(value.size()), 4, RoundingMode.HALF_UP); + keyToValue.setValue(mean); + } else { + keyToValue.setValue(sum); + } + keyToValues.add(keyToValue); + evaluatedDataAmount.addAndGet(value.size()); + }); + + ChoroplethMapData choroplethMapData = new ChoroplethMapData(); + choroplethMapData.addKeyToValues(keyToValues); + choroplethMapData.setEvaluatedDataAmount(evaluatedDataAmount.get()); + + Diagram diagram = + AnalysisMapper.mapToPersistence(addDiagramRequest, choroplethMapData, analysis); + + analysisRepository.flush(); + return diagram.getExternalId(); + } +} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/DataPointHolder.java b/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/DataPointHolder.java similarity index 83% rename from backend/statistics/src/main/java/de/eshg/statistics/aggregation/DataPointHolder.java rename to backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/DataPointHolder.java index b9d65954edf445481fd81fb58e3142c8742d9909..544d327bb10ac30af62981c18336944e2886319e 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/aggregation/DataPointHolder.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/DataPointHolder.java @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package de.eshg.statistics.aggregation; +package de.eshg.statistics.diagramcreation; import java.math.BigDecimal; diff --git a/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/DiagramCreationService.java b/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/DiagramCreationService.java new file mode 100644 index 0000000000000000000000000000000000000000..04d277a47e10f81720989af4163bc0ccd81bf9ac --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/DiagramCreationService.java @@ -0,0 +1,155 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.diagramcreation; + +import de.eshg.statistics.aggregation.EvaluationService; +import de.eshg.statistics.api.AddDiagramRequest; +import de.eshg.statistics.api.AnalysisDto; +import de.eshg.statistics.api.chart.BarChartConfigurationDto; +import de.eshg.statistics.api.chart.ChoroplethMapConfigurationDto; +import de.eshg.statistics.api.chart.HistogramChartConfigurationDto; +import de.eshg.statistics.api.chart.LineChartConfigurationDto; +import de.eshg.statistics.api.chart.PieChartConfigurationDto; +import de.eshg.statistics.api.chart.ScatterChartConfigurationDto; +import de.eshg.statistics.mapper.AnalysisMapper; +import de.eshg.statistics.mapper.FilterParameterMapper; +import de.eshg.statistics.persistence.entity.AggregationResultState; +import de.eshg.statistics.persistence.entity.Evaluation; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DiagramCreationService { + private final BarChartDiagramCreationService barChartDiagramCreationService; + private final ChoroplethMapDiagramCreationService choroplethMapDiagramCreationService; + private final HistogramChartDiagramCreationService histogramChartDiagramCreationService; + private final PieChartDiagramCreationService pieChartDiagramCreationService; + private final PointBasedChartDiagramCreationService pointBasedChartDiagramCreationService; + private final EvaluationService evaluationService; + + public DiagramCreationService( + BarChartDiagramCreationService barChartDiagramCreationService, + ChoroplethMapDiagramCreationService choroplethMapDiagramCreationService, + HistogramChartDiagramCreationService histogramChartDiagramCreationService, + PieChartDiagramCreationService pieChartDiagramCreationService, + PointBasedChartDiagramCreationService pointBasedChartDiagramCreationService, + EvaluationService evaluationService) { + this.barChartDiagramCreationService = barChartDiagramCreationService; + this.choroplethMapDiagramCreationService = choroplethMapDiagramCreationService; + this.histogramChartDiagramCreationService = histogramChartDiagramCreationService; + this.pieChartDiagramCreationService = pieChartDiagramCreationService; + this.pointBasedChartDiagramCreationService = pointBasedChartDiagramCreationService; + this.evaluationService = evaluationService; + } + + public UUID createDiagram(AnalysisDto analysisDto, AddDiagramRequest addDiagramRequest) { + UUID analysisId = analysisDto.id(); + + return switch (analysisDto.chartConfiguration()) { + case BarChartConfigurationDto barChartConfigurationDto -> + addDiagramWithData( + barChartDiagramCreationService, + analysisId, + barChartConfigurationDto, + addDiagramRequest); + case ChoroplethMapConfigurationDto choroplethMapConfigurationDto -> + addDiagramWithData( + choroplethMapDiagramCreationService, + analysisId, + choroplethMapConfigurationDto, + addDiagramRequest); + case HistogramChartConfigurationDto histogramChartConfigurationDto -> + addDiagramWithData( + histogramChartDiagramCreationService, + analysisId, + histogramChartConfigurationDto, + addDiagramRequest); + case LineChartConfigurationDto lineChartConfigurationDto -> + addDiagramWithData( + pointBasedChartDiagramCreationService, + analysisId, + lineChartConfigurationDto, + addDiagramRequest); + case PieChartConfigurationDto pieChartConfigurationDto -> + addDiagramWithData( + pieChartDiagramCreationService, + analysisId, + pieChartConfigurationDto, + addDiagramRequest); + case ScatterChartConfigurationDto scatterChartConfigurationDto -> + addDiagramWithData( + pointBasedChartDiagramCreationService, + analysisId, + scatterChartConfigurationDto, + addDiagramRequest); + }; + } + + private static <D, C> UUID addDiagramWithData( + AbstractChartDiagramCreationService<D, C> service, + UUID analysisId, + C chartConfigurationDto, + AddDiagramRequest addDiagramRequest) { + D chartDataHolder = service.initializeChartDataHolder(); + collectData(service, analysisId, chartConfigurationDto, addDiagramRequest, chartDataHolder); + return service.addDiagram( + analysisId, chartConfigurationDto, addDiagramRequest, chartDataHolder); + } + + private static <D, C> void collectData( + AbstractChartDiagramCreationService<D, C> service, + UUID analysisId, + C chartConfigurationDto, + AddDiagramRequest addDiagramRequest, + D chartDataHolder) { + int page = 0; + int maxPage; + while (true) { + maxPage = + service.collectChartData( + analysisId, + chartConfigurationDto, + addDiagramRequest.filters(), + page, + chartDataHolder); + if (page >= maxPage) { + break; + } + page++; + } + } + + @Transactional + public void diagramRecreation(UUID evaluationId) { + Evaluation evaluation = evaluationService.getEvaluationInternal(evaluationId); + recreateDiagrams(evaluation); + evaluation.setPendingState(null); + evaluation.setState(AggregationResultState.COMPLETED); + } + + private void recreateDiagrams(Evaluation evaluation) { + evaluation + .getAnalyses() + .forEach( + analysis -> { + AnalysisDto analysisDto = AnalysisMapper.mapToApi(analysis, true); + List<AddDiagramRequest> addDiagramRequests = + analysis.getDiagrams().stream() + .map( + diagram -> + new AddDiagramRequest( + diagram.getTitle(), + diagram.getDescription(), + FilterParameterMapper.mapToApi(diagram.getFilters()))) + .toList(); + analysis.removeDiagrams(); + addDiagramRequests.forEach( + addDiagramRequest -> createDiagram(analysisDto, addDiagramRequest)); + }); + } +} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/HistogramChartDiagramCreationService.java b/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/HistogramChartDiagramCreationService.java new file mode 100644 index 0000000000000000000000000000000000000000..3cde9cb7db05a21a2b763b133e35cfb4dd772467 --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/HistogramChartDiagramCreationService.java @@ -0,0 +1,243 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.diagramcreation; + +import de.eshg.domain.model.BaseEntity; +import de.eshg.statistics.aggregation.AggregationResultUtil; +import de.eshg.statistics.aggregation.AnalysisService; +import de.eshg.statistics.aggregation.TableRowSpecifications; +import de.eshg.statistics.api.AddDiagramRequest; +import de.eshg.statistics.api.chart.HistogramChartConfigurationDto; +import de.eshg.statistics.api.filter.TableColumnFilterParameter; +import de.eshg.statistics.config.StatisticsConfig; +import de.eshg.statistics.mapper.AnalysisMapper; +import de.eshg.statistics.persistence.entity.AbstractAggregationResult; +import de.eshg.statistics.persistence.entity.Analysis; +import de.eshg.statistics.persistence.entity.ChartConfiguration; +import de.eshg.statistics.persistence.entity.Diagram; +import de.eshg.statistics.persistence.entity.TableColumn; +import de.eshg.statistics.persistence.entity.TableColumnValueType; +import de.eshg.statistics.persistence.entity.TableRow; +import de.eshg.statistics.persistence.entity.chart.HistogramBin; +import de.eshg.statistics.persistence.entity.chart.HistogramChartConfiguration; +import de.eshg.statistics.persistence.entity.diagramdata.HistogramChartData; +import de.eshg.statistics.persistence.entity.diagramdata.HistogramGroupData; +import de.eshg.statistics.persistence.entity.diagramdata.KeyToCount; +import de.eshg.statistics.persistence.repository.AnalysisRepository; +import de.eshg.statistics.persistence.repository.TableRowRepository; +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; +import org.hibernate.Hibernate; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class HistogramChartDiagramCreationService + extends AbstractChartDiagramCreationService< + Map<Long, Map<String, Integer>>, HistogramChartConfigurationDto> { + public HistogramChartDiagramCreationService( + AnalysisService analysisService, + AnalysisRepository analysisRepository, + TableRowRepository tableRowRepository, + StatisticsConfig statisticsConfig) { + super(analysisService, analysisRepository, tableRowRepository, statisticsConfig); + } + + @Override + Map<Long, Map<String, Integer>> initializeChartDataHolder() { + return new HashMap<>(); + } + + @Override + @Transactional(readOnly = true) + public int collectChartData( + UUID analysisId, + HistogramChartConfigurationDto histogramChartConfigurationDto, + List<TableColumnFilterParameter> filters, + int page, + Map<Long, Map<String, Integer>> chartDataHolder) { + Analysis analysis = analysisService.getAnalysisInternal(analysisId); + AbstractAggregationResult aggregationResult = analysis.getAggregationResult(); + HistogramChartConfiguration chartConfiguration = + (HistogramChartConfiguration) + Hibernate.unproxy(analysis.getChartConfiguration(), ChartConfiguration.class); + + if (chartConfiguration.getBins().isEmpty()) { + return 0; + } + + TableColumn primaryTableColumn = + AggregationResultUtil.getTableColumn( + histogramChartConfigurationDto.primaryAttribute(), aggregationResult); + TableColumn secondaryTableColumn = + AggregationResultUtil.getTableColumn( + histogramChartConfigurationDto.secondaryAttribute(), aggregationResult); + if (page == 0) { + AggregationResultUtil.validateColumnFilters(filters, aggregationResult); + } + + Specification<TableRow> notNullNotUnknownSpecification = + TableRowSpecifications.getNotNullAndNotUnknownSpecificationDecimalAndInteger( + primaryTableColumn); + + Stream<Specification<TableRow>> specificationStream; + if (secondaryTableColumn == null) { + specificationStream = Stream.of(notNullNotUnknownSpecification); + } else { + specificationStream = + Stream.of( + notNullNotUnknownSpecification, + TableRowSpecifications.getNotNullSpecification(secondaryTableColumn)); + } + + return collectDataForTablePageAndReturnMaxPage( + page, + specificationStream, + filters, + aggregationResult, + tableRow -> + addTableRowToCollectedHistogramChartData( + tableRow, + chartDataHolder, + chartConfiguration.getBins(), + primaryTableColumn, + secondaryTableColumn)); + } + + private static void addTableRowToCollectedHistogramChartData( + TableRow tableRow, + Map<Long, Map<String, Integer>> chartDataHolder, + List<HistogramBin> bins, + TableColumn primaryTableColumn, + TableColumn secondaryTableColumn) { + BigDecimal value = + getValueAsBigDecimal( + primaryTableColumn.getValueType(), getCellEntry(tableRow, primaryTableColumn)); + + Long primaryKey = + bins.stream() + .filter( + bin -> + (bin.getLowerBound().compareTo(value) <= 0) + && (bin.getUpperBound().compareTo(value) >= 0)) + .findFirst() + .map(BaseEntity::getId) + .orElse(null); + + String secondaryKey; + if (secondaryTableColumn == null) { + secondaryKey = String.valueOf(primaryKey); + } else { + secondaryKey = + getKeyForCellEntryBooleanTextOrValueOption(getCellEntry(tableRow, secondaryTableColumn)); + } + + addTableRowToCollectedChartData(primaryKey, secondaryKey, chartDataHolder); + } + + @Override + @Transactional + public UUID addDiagram( + UUID analysisId, + HistogramChartConfigurationDto histogramChartConfigurationDto, + AddDiagramRequest addDiagramRequest, + Map<Long, Map<String, Integer>> chartDataHolder) { + Analysis analysis = analysisService.getAnalysisInternal(analysisId); + HistogramChartConfiguration chartConfiguration = + (HistogramChartConfiguration) + Hibernate.unproxy(analysis.getChartConfiguration(), ChartConfiguration.class); + fillHistogramChartDataWithMissingValues( + chartDataHolder, + chartConfiguration.getBins(), + analysis.getAggregationResult(), + histogramChartConfigurationDto); + + List<HistogramGroupData> histogramGroupDatas = + chartConfiguration.getBins().stream() + .map( + bin -> + mapToHistogramGroupData( + bin, + chartDataHolder, + histogramChartConfigurationDto.secondaryAttribute() != null)) + .toList(); + + int evaluatedEntries = + histogramGroupDatas.stream() + .map( + groupData -> { + if (groupData.getCount() == null) { + return groupData.getKeyToCounts().stream().mapToInt(KeyToCount::getCount).sum(); + } else { + return groupData.getCount(); + } + }) + .mapToInt(groupDataCount -> groupDataCount) + .sum(); + + HistogramChartData histogramChartData = new HistogramChartData(); + histogramChartData.setEvaluatedDataAmount(evaluatedEntries); + histogramChartData.addHistogramGroupDatas(histogramGroupDatas); + + Diagram diagram = + AnalysisMapper.mapToPersistence(addDiagramRequest, histogramChartData, analysis); + + analysisRepository.flush(); + return diagram.getExternalId(); + } + + private static void fillHistogramChartDataWithMissingValues( + Map<Long, Map<String, Integer>> chartDataHolder, + List<HistogramBin> bins, + AbstractAggregationResult aggregationResult, + HistogramChartConfigurationDto histogramChartConfigurationDto) { + TableColumn secondaryTableColumn = + AggregationResultUtil.getTableColumn( + histogramChartConfigurationDto.secondaryAttribute(), aggregationResult); + bins.forEach(bin -> chartDataHolder.computeIfAbsent(bin.getId(), k -> new HashMap<>())); + if (secondaryTableColumn == null) { + chartDataHolder.forEach( + (key, secondaryMap) -> { + String stringKey = String.valueOf(key); + secondaryMap.computeIfAbsent(stringKey, k -> 0); + }); + } else { + Set<String> secondaryKeys; + if (secondaryTableColumn.getValueType().equals(TableColumnValueType.TEXT)) { + secondaryKeys = getKeysForTextValues(chartDataHolder); + } else { + secondaryKeys = getKeysForBooleanOrValueOption(secondaryTableColumn); + } + chartDataHolder + .values() + .forEach( + secondaryMap -> + secondaryKeys.forEach(key -> secondaryMap.computeIfAbsent(key, k -> 0))); + } + } + + private static HistogramGroupData mapToHistogramGroupData( + HistogramBin bin, + Map<Long, Map<String, Integer>> chartDataHolder, + boolean withSecondaryAttribute) { + HistogramGroupData histogramGroupData = new HistogramGroupData(); + bin.addHistogramGroupData(histogramGroupData); + + Map<String, Integer> dataForBin = chartDataHolder.get(bin.getId()); + if (withSecondaryAttribute) { + histogramGroupData.addKeyToCounts(mapToSortedKeyToCountList(dataForBin)); + } else { + histogramGroupData.setCount(dataForBin.values().stream().mapToInt(count -> count).sum()); + } + return histogramGroupData; + } +} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/PieChartDiagramCreationService.java b/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/PieChartDiagramCreationService.java new file mode 100644 index 0000000000000000000000000000000000000000..57fe29525620690ab3cb8c5cce1b2714a2e5f29a --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/PieChartDiagramCreationService.java @@ -0,0 +1,118 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.diagramcreation; + +import de.eshg.statistics.aggregation.AggregationResultUtil; +import de.eshg.statistics.aggregation.AnalysisService; +import de.eshg.statistics.aggregation.TableRowSpecifications; +import de.eshg.statistics.api.AddDiagramRequest; +import de.eshg.statistics.api.chart.PieChartConfigurationDto; +import de.eshg.statistics.api.filter.TableColumnFilterParameter; +import de.eshg.statistics.config.StatisticsConfig; +import de.eshg.statistics.mapper.AnalysisMapper; +import de.eshg.statistics.persistence.entity.AbstractAggregationResult; +import de.eshg.statistics.persistence.entity.Analysis; +import de.eshg.statistics.persistence.entity.Diagram; +import de.eshg.statistics.persistence.entity.TableColumn; +import de.eshg.statistics.persistence.entity.TableRow; +import de.eshg.statistics.persistence.entity.diagramdata.KeyToCount; +import de.eshg.statistics.persistence.entity.diagramdata.PieChartData; +import de.eshg.statistics.persistence.repository.AnalysisRepository; +import de.eshg.statistics.persistence.repository.TableRowRepository; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class PieChartDiagramCreationService + extends AbstractChartDiagramCreationService<Map<String, Integer>, PieChartConfigurationDto> { + public PieChartDiagramCreationService( + AnalysisService analysisService, + AnalysisRepository analysisRepository, + TableRowRepository tableRowRepository, + StatisticsConfig statisticsConfig) { + super(analysisService, analysisRepository, tableRowRepository, statisticsConfig); + } + + @Override + Map<String, Integer> initializeChartDataHolder() { + return new HashMap<>(); + } + + @Override + @Transactional(readOnly = true) + public int collectChartData( + UUID analysisId, + PieChartConfigurationDto pieChartConfigurationDto, + List<TableColumnFilterParameter> filters, + int page, + Map<String, Integer> chartDataHolder) { + Analysis analysis = analysisService.getAnalysisInternal(analysisId); + AbstractAggregationResult aggregationResult = analysis.getAggregationResult(); + + TableColumn tableColumn = + AggregationResultUtil.getTableColumn( + pieChartConfigurationDto.attribute(), aggregationResult); + if (page == 0) { + AggregationResultUtil.validateColumnFilters(filters, aggregationResult); + initiallyFillPieChartMap(chartDataHolder, tableColumn); + } + + Stream<Specification<TableRow>> notNullSpecifications = + Stream.of(TableRowSpecifications.getNotNullSpecification(tableColumn)); + + return collectDataForTablePageAndReturnMaxPage( + page, + notNullSpecifications, + filters, + aggregationResult, + tableRow -> addTableRowToCollectedPieChartData(tableRow, chartDataHolder, tableColumn)); + } + + private static void initiallyFillPieChartMap( + Map<String, Integer> chartDataHolder, TableColumn tableColumn) { + Set<String> keys = getKeysForBooleanOrValueOption(tableColumn); + keys.forEach(key -> chartDataHolder.put(key, 0)); + } + + private static void addTableRowToCollectedPieChartData( + TableRow tableRow, Map<String, Integer> collectedChartData, TableColumn tableColumn) { + String primaryKey = + getKeyForCellEntryBooleanTextOrValueOption(getCellEntry(tableRow, tableColumn)); + if (primaryKey != null) { + collectedChartData.compute(primaryKey, (key, count) -> (count == null) ? 1 : count + 1); + } + } + + @Override + @Transactional + public UUID addDiagram( + UUID analysisId, + PieChartConfigurationDto ignored, + AddDiagramRequest addDiagramRequest, + Map<String, Integer> chartDataHolder) { + Analysis analysis = analysisService.getAnalysisInternal(analysisId); + + List<KeyToCount> keyToCounts = mapToSortedKeyToCountList(chartDataHolder); + + int evaluatedEntries = keyToCounts.stream().mapToInt(KeyToCount::getCount).sum(); + + PieChartData pieChartData = new PieChartData(); + pieChartData.setEvaluatedDataAmount(evaluatedEntries); + pieChartData.addKeyToCounts(keyToCounts); + + Diagram diagram = AnalysisMapper.mapToPersistence(addDiagramRequest, pieChartData, analysis); + + analysisRepository.flush(); + return diagram.getExternalId(); + } +} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/PointBasedChartDiagramCreationService.java b/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/PointBasedChartDiagramCreationService.java new file mode 100644 index 0000000000000000000000000000000000000000..27e068255d14e7dfd5086f413b2084efe5049dd6 --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/diagramcreation/PointBasedChartDiagramCreationService.java @@ -0,0 +1,267 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.diagramcreation; + +import de.eshg.statistics.aggregation.AggregationResultUtil; +import de.eshg.statistics.aggregation.AnalysisService; +import de.eshg.statistics.aggregation.TableRowSpecifications; +import de.eshg.statistics.api.AddDiagramRequest; +import de.eshg.statistics.api.chart.PointBasedChartConfigurationDto; +import de.eshg.statistics.api.chart.ScatterChartConfigurationDto; +import de.eshg.statistics.api.filter.TableColumnFilterParameter; +import de.eshg.statistics.config.StatisticsConfig; +import de.eshg.statistics.mapper.AnalysisMapper; +import de.eshg.statistics.persistence.entity.AbstractAggregationResult; +import de.eshg.statistics.persistence.entity.Analysis; +import de.eshg.statistics.persistence.entity.CellEntry; +import de.eshg.statistics.persistence.entity.Diagram; +import de.eshg.statistics.persistence.entity.TableColumn; +import de.eshg.statistics.persistence.entity.TableRow; +import de.eshg.statistics.persistence.entity.diagramdata.DataPoint; +import de.eshg.statistics.persistence.entity.diagramdata.DataPointGroup; +import de.eshg.statistics.persistence.entity.diagramdata.LineOrScatterChartData; +import de.eshg.statistics.persistence.entity.diagramdata.TrendLine; +import de.eshg.statistics.persistence.repository.AnalysisRepository; +import de.eshg.statistics.persistence.repository.TableRowRepository; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class PointBasedChartDiagramCreationService + extends AbstractChartDiagramCreationService< + Map<String, List<DataPointHolder>>, PointBasedChartConfigurationDto> { + public PointBasedChartDiagramCreationService( + AnalysisService analysisService, + AnalysisRepository analysisRepository, + TableRowRepository tableRowRepository, + StatisticsConfig statisticsConfig) { + super(analysisService, analysisRepository, tableRowRepository, statisticsConfig); + } + + @Override + Map<String, List<DataPointHolder>> initializeChartDataHolder() { + return new HashMap<>(); + } + + @Override + @Transactional(readOnly = true) + public int collectChartData( + UUID analysisId, + PointBasedChartConfigurationDto pointBasedChartConfiguration, + List<TableColumnFilterParameter> filters, + int page, + Map<String, List<DataPointHolder>> chartDataHolder) { + Analysis analysis = analysisService.getAnalysisInternal(analysisId); + AbstractAggregationResult aggregationResult = analysis.getAggregationResult(); + + TableColumn secondaryTableColumn = + AggregationResultUtil.getTableColumn( + pointBasedChartConfiguration.secondaryAttribute(), aggregationResult); + if (page == 0) { + AggregationResultUtil.validateColumnFilters(filters, aggregationResult); + initiallyFillPointBasedChartMap(chartDataHolder, secondaryTableColumn); + } + + TableColumn xTableColumn = + AggregationResultUtil.getTableColumn( + pointBasedChartConfiguration.xAttribute(), aggregationResult); + TableColumn yTableColumn = + AggregationResultUtil.getTableColumn( + pointBasedChartConfiguration.yAttribute(), aggregationResult); + + List<Specification<TableRow>> notNullSpecifications = + getNotNullSpecificationsForDataPointCharts( + xTableColumn, yTableColumn, secondaryTableColumn); + + return collectDataForTablePageAndReturnMaxPage( + page, + notNullSpecifications.stream(), + filters, + aggregationResult, + tableRow -> + addTableRowToCollectedPointBasedChartData( + tableRow, chartDataHolder, xTableColumn, yTableColumn, secondaryTableColumn)); + } + + private static void initiallyFillPointBasedChartMap( + Map<String, List<DataPointHolder>> chartDataHolder, TableColumn secondaryTableColumn) { + Set<String> secondaryKeys = getKeysForBooleanOrValueOption(secondaryTableColumn); + secondaryKeys.forEach(key -> chartDataHolder.put(key, new ArrayList<>())); + } + + private static List<Specification<TableRow>> getNotNullSpecificationsForDataPointCharts( + TableColumn xTableColumn, TableColumn yTableColumn, TableColumn secondaryTableColumn) { + List<Specification<TableRow>> notNullSpecifications = new ArrayList<>(); + notNullSpecifications.add( + TableRowSpecifications.getNotNullAndNotUnknownSpecificationDecimalAndInteger(xTableColumn)); + notNullSpecifications.add( + TableRowSpecifications.getNotNullAndNotUnknownSpecificationDecimalAndInteger(yTableColumn)); + + if (secondaryTableColumn != null) { + notNullSpecifications.add( + TableRowSpecifications.getNotNullSpecification(secondaryTableColumn)); + } + + return notNullSpecifications; + } + + private static void addTableRowToCollectedPointBasedChartData( + TableRow tableRow, + Map<String, List<DataPointHolder>> chartDataHolder, + TableColumn xTableColumn, + TableColumn yTableColumn, + TableColumn secondaryTableColumn) { + + BigDecimal xValue = + getValueAsBigDecimal(xTableColumn.getValueType(), getCellEntry(tableRow, xTableColumn)); + BigDecimal yValue = + getValueAsBigDecimal(yTableColumn.getValueType(), getCellEntry(tableRow, yTableColumn)); + + if (secondaryTableColumn == null) { + chartDataHolder + .computeIfAbsent("", key -> new ArrayList<>()) + .add(new DataPointHolder(tableRow.getId(), xValue, yValue, null)); + } else { + CellEntry secondaryCellEntry = getCellEntry(tableRow, secondaryTableColumn); + String secondaryKey = getKeyForCellEntryBooleanTextOrValueOption(secondaryCellEntry); + if (secondaryKey != null) { + chartDataHolder + .computeIfAbsent(secondaryKey, key -> new ArrayList<>()) + .add(new DataPointHolder(tableRow.getId(), xValue, yValue, secondaryKey)); + } + } + } + + @Override + @Transactional + public UUID addDiagram( + UUID analysisId, + PointBasedChartConfigurationDto pointBasedChartConfiguration, + AddDiagramRequest addDiagramRequest, + Map<String, List<DataPointHolder>> chartDataHolder) { + Analysis analysis = analysisService.getAnalysisInternal(analysisId); + + Comparator<DataPointHolder> comparator = + Comparator.comparing(DataPointHolder::xCoordinate) + .thenComparing(DataPointHolder::yCoordinate) + .thenComparing(DataPointHolder::rowId); + Function<DataPointHolder, DataPoint> mapFunction = + dataPointHolder -> + getDataPoint(dataPointHolder.xCoordinate(), dataPointHolder.yCoordinate()); + + AtomicInteger evaluatedDataAmount = new AtomicInteger(0); + List<DataPointGroup> dataPointGroups = new ArrayList<>(); + if (pointBasedChartConfiguration.secondaryAttribute() == null) { + List<DataPoint> dataPoints = + chartDataHolder.computeIfAbsent("", key -> new ArrayList<>()).stream() + .sorted(comparator) + .map(mapFunction) + .toList(); + DataPointGroup dataPointGroup = new DataPointGroup(); + dataPointGroup.addDataPoints(dataPoints); + dataPointGroups.add(dataPointGroup); + evaluatedDataAmount.addAndGet(dataPoints.size()); + } else { + chartDataHolder.keySet().stream() + .sorted() + .forEach( + key -> { + List<DataPoint> dataPoints = + chartDataHolder.get(key).stream().sorted(comparator).map(mapFunction).toList(); + DataPointGroup dataPointGroup = new DataPointGroup(); + dataPointGroup.setKey(key); + dataPointGroup.addDataPoints(dataPoints); + dataPointGroups.add(dataPointGroup); + evaluatedDataAmount.addAndGet(dataPoints.size()); + }); + } + + if (pointBasedChartConfiguration + instanceof ScatterChartConfigurationDto scatterChartConfigurationDto + && scatterChartConfigurationDto.trendLine()) { + dataPointGroups.forEach( + dataPointGroup -> dataPointGroup.setTrendLine(determineTrendLine(dataPointGroup))); + } + + LineOrScatterChartData lineOrScatterChartData = new LineOrScatterChartData(); + lineOrScatterChartData.addDataPointGroups(dataPointGroups); + lineOrScatterChartData.setEvaluatedDataAmount(evaluatedDataAmount.get()); + + Diagram diagram = + AnalysisMapper.mapToPersistence(addDiagramRequest, lineOrScatterChartData, analysis); + + analysisRepository.flush(); + return diagram.getExternalId(); + } + + private static DataPoint getDataPoint(BigDecimal xCoordinate, BigDecimal yCoordinate) { + DataPoint dataPoint = new DataPoint(); + dataPoint.setXCoordinate(xCoordinate); + dataPoint.setYCoordinate(yCoordinate); + return dataPoint; + } + + private static TrendLine determineTrendLine(DataPointGroup dataPointGroup) { + if (dataPointGroup.getDataPoints().size() < 2) { + return null; + } + + BigDecimal averageX = + calculateAverageOfDataPointCoordinate(dataPointGroup, DataPoint::getXCoordinate); + BigDecimal averageY = + calculateAverageOfDataPointCoordinate(dataPointGroup, DataPoint::getYCoordinate); + + BigDecimal numerator = + dataPointGroup.getDataPoints().stream() + .map( + dataPoint -> + dataPoint + .getXCoordinate() + .subtract(averageX) + .multiply(dataPoint.getYCoordinate().subtract(averageY))) + .reduce(BigDecimal::add) + .orElseThrow(); + BigDecimal denominator = + dataPointGroup.getDataPoints().stream() + .map(dataPoint -> dataPoint.getXCoordinate().subtract(averageX).pow(2)) + .reduce(BigDecimal::add) + .orElseThrow(); + + if (denominator.setScale(4, RoundingMode.HALF_UP).compareTo(BigDecimal.ZERO) == 0) { + return null; + } + + BigDecimal lineSlope = numerator.divide(denominator, RoundingMode.HALF_UP); + BigDecimal lineOffset = averageY.subtract(lineSlope.multiply(averageX)); + + TrendLine trendLine = new TrendLine(); + trendLine.setLineSlope(lineSlope.setScale(4, RoundingMode.HALF_UP)); + trendLine.setLineOffset(lineOffset.setScale(4, RoundingMode.HALF_UP)); + return trendLine; + } + + private static BigDecimal calculateAverageOfDataPointCoordinate( + DataPointGroup dataPointGroup, Function<DataPoint, BigDecimal> coordinateFunction) { + return dataPointGroup.getDataPoints().stream() + .map(coordinateFunction) + .reduce(BigDecimal::add) + .orElseThrow() + .setScale(8, RoundingMode.HALF_UP) + .divide(BigDecimal.valueOf(dataPointGroup.getDataPoints().size()), RoundingMode.HALF_UP); + } +} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/mapper/EvaluationMapper.java b/backend/statistics/src/main/java/de/eshg/statistics/mapper/EvaluationMapper.java index dd8e289762a7a99425831699d77a3628eaf5a424..daa1fb03db674ff171718403c7ab0c02009943cc 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/mapper/EvaluationMapper.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/mapper/EvaluationMapper.java @@ -71,6 +71,10 @@ public class EvaluationMapper { } private static TableColumnHeader mapToApi(TableColumn tableColumn) { + TableColumnDataPrivacyCategory dataPrivacyCategory = + tableColumn.getAnonymizationConfiguration() == null + ? null + : tableColumn.getAnonymizationConfiguration().getDataPrivacyCategory(); if (tableColumn.getBaseModuleAttributeCode() == null) { return new TableColumnHeader( getAttributeDisplayName(tableColumn, false), @@ -84,7 +88,7 @@ public class EvaluationMapper { tableColumn.getUnit(), tableColumn.getValueToMeanings(), tableColumn.getMinMaxNullUnknownValues()), - mapDataPrivacyCategory(tableColumn.getDataPrivacyCategory())); + mapDataPrivacyCategory(dataPrivacyCategory)); } else { return new TableColumnHeader( getAttributeDisplayName(tableColumn, false), @@ -101,7 +105,7 @@ public class EvaluationMapper { tableColumn.getUnit(), tableColumn.getValueToMeanings(), tableColumn.getMinMaxNullUnknownValues())), - mapDataPrivacyCategory(tableColumn.getDataPrivacyCategory())); + mapDataPrivacyCategory(dataPrivacyCategory)); } } diff --git a/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/AnonymizationConfiguration.java b/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/AnonymizationConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..00752a552ed699529957e125268a5906e45857c6 --- /dev/null +++ b/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/AnonymizationConfiguration.java @@ -0,0 +1,117 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.statistics.persistence.entity; + +import static de.eshg.lib.common.SensitivityLevel.PUBLIC; + +import de.eshg.domain.model.BaseEntity; +import de.eshg.lib.common.DataSensitivity; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import java.math.BigDecimal; +import java.util.*; +import org.hibernate.annotations.JdbcType; +import org.hibernate.dialect.PostgreSQLEnumJdbcType; + +@Entity +@DataSensitivity(PUBLIC) +public class AnonymizationConfiguration extends BaseEntity { + + @Column + @JdbcType(PostgreSQLEnumJdbcType.class) + private TableColumnDataPrivacyCategory dataPrivacyCategory; + + @Column private Integer intervalCount; + + @Column(precision = 10, scale = 4) + private BigDecimal minDecimalInclusive; + + @Column(precision = 10, scale = 4) + private BigDecimal maxDecimalInclusive; + + @ElementCollection + @CollectionTable(name = "decimal_interval_border_values", joinColumns = @JoinColumn(name = "id")) + @Column(name = "border", precision = 10, scale = 4, nullable = false) + private List<BigDecimal> decimalBorders = new ArrayList<>(); + + @Column private Integer minIntegerInclusive; + + @Column private Integer maxIntegerInclusive; + + @ElementCollection + @CollectionTable(name = "integer_interval_border_values", joinColumns = @JoinColumn(name = "id")) + @Column(name = "border", nullable = false) + private List<Integer> integerBorders = new ArrayList<>(); + + public TableColumnDataPrivacyCategory getDataPrivacyCategory() { + return dataPrivacyCategory; + } + + public void setDataPrivacyCategory(TableColumnDataPrivacyCategory dataPrivacyCategory) { + this.dataPrivacyCategory = dataPrivacyCategory; + } + + public Integer getIntervalCount() { + return intervalCount; + } + + public void setIntervalCount(Integer intervalCount) { + this.intervalCount = intervalCount; + } + + public BigDecimal getMinDecimalInclusive() { + return minDecimalInclusive; + } + + public void setMinDecimalInclusive(BigDecimal minDecimalInclusive) { + this.minDecimalInclusive = minDecimalInclusive; + } + + public BigDecimal getMaxDecimalInclusive() { + return maxDecimalInclusive; + } + + public void setMaxDecimalInclusive(BigDecimal maxDecimalInclusive) { + this.maxDecimalInclusive = maxDecimalInclusive; + } + + public Set<BigDecimal> getDecimalBorders() { + return new TreeSet<>(decimalBorders); + } + + public void setDecimalBorders(Collection<BigDecimal> decimalBorders) { + this.decimalBorders.clear(); + this.decimalBorders.addAll(decimalBorders); + } + + public Integer getMinIntegerInclusive() { + return minIntegerInclusive; + } + + public void setMinIntegerInclusive(Integer minIntegerInclusive) { + this.minIntegerInclusive = minIntegerInclusive; + } + + public Integer getMaxIntegerInclusive() { + return maxIntegerInclusive; + } + + public void setMaxIntegerInclusive(Integer maxIntegerInclusive) { + this.maxIntegerInclusive = maxIntegerInclusive; + } + + public Set<Integer> getIntegerBorders() { + return new TreeSet<>(integerBorders); + } + + public void setIntegerBorders(Collection<Integer> integerBorders) { + this.integerBorders.clear(); + this.integerBorders.addAll(integerBorders); + } +} diff --git a/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/TableColumn.java b/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/TableColumn.java index f42f28c3277735541b8184d8ceb93cef10eefde9..11d64a3188f0f58adfb367859ad250fbd88b07f2 100644 --- a/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/TableColumn.java +++ b/backend/statistics/src/main/java/de/eshg/statistics/persistence/entity/TableColumn.java @@ -52,10 +52,6 @@ public class TableColumn extends BaseEntity { @JdbcType(PostgreSQLEnumJdbcType.class) private TableColumnValueType valueType; - @Column - @JdbcType(PostgreSQLEnumJdbcType.class) - private TableColumnDataPrivacyCategory dataPrivacyCategory; - @Column private String unit; @OneToMany( @@ -86,6 +82,9 @@ public class TableColumn extends BaseEntity { orphanRemoval = true) private MinMaxNullUnknownValues minMaxNullUnknownValues; + @OneToOne(cascade = CascadeType.PERSIST, fetch = FetchType.LAZY, orphanRemoval = true) + private AnonymizationConfiguration anonymizationConfiguration; + @Column(nullable = false) private String searchKey; @@ -145,14 +144,6 @@ public class TableColumn extends BaseEntity { this.valueType = valueType; } - public TableColumnDataPrivacyCategory getDataPrivacyCategory() { - return dataPrivacyCategory; - } - - public void setDataPrivacyCategory(TableColumnDataPrivacyCategory dataPrivacyCategory) { - this.dataPrivacyCategory = dataPrivacyCategory; - } - public String getUnit() { return unit; } @@ -216,6 +207,14 @@ public class TableColumn extends BaseEntity { this.minMaxNullUnknownValues = minMaxNullUnknownValues; } + public AnonymizationConfiguration getAnonymizationConfiguration() { + return anonymizationConfiguration; + } + + public void setAnonymizationConfiguration(AnonymizationConfiguration anonymizationConfiguration) { + this.anonymizationConfiguration = anonymizationConfiguration; + } + public String getSearchKey() { return searchKey; } diff --git a/backend/statistics/src/main/resources/migrations/0048_anonymization_configuration.xml b/backend/statistics/src/main/resources/migrations/0048_anonymization_configuration.xml new file mode 100644 index 0000000000000000000000000000000000000000..de275b6b825918ea58e562656a536ddc857563e6 --- /dev/null +++ b/backend/statistics/src/main/resources/migrations/0048_anonymization_configuration.xml @@ -0,0 +1,52 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 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="1738858770228-1"> + <createTable tableName="anonymization_configuration"> + <column autoIncrement="true" name="id" type="BIGINT"> + <constraints nullable="false" primaryKey="true" primaryKeyName="pk_anonymization_configuration"/> + </column> + <column name="version" type="BIGINT"> + <constraints nullable="false"/> + </column> + <column name="data_privacy_category" type="TABLECOLUMNDATAPRIVACYCATEGORY"/> + <column name="interval_count" type="INTEGER"/> + <column name="min_decimal_inclusive" type="numeric(10, 4)"/> + <column name="max_decimal_inclusive" type="numeric(10, 4)"/> + <column name="min_integer_inclusive" type="INTEGER"/> + <column name="max_integer_inclusive" type="INTEGER"/> + </createTable> + + <createTable tableName="decimal_interval_border_values"> + <column name="id" type="BIGINT"> + <constraints nullable="false"/> + </column> + <column name="border" type="numeric(10, 4)"> + <constraints nullable="false"/> + </column> + </createTable> + + <createTable tableName="integer_interval_border_values"> + <column name="id" type="BIGINT"> + <constraints nullable="false"/> + </column> + <column name="border" type="INTEGER"> + <constraints nullable="false"/> + </column> + </createTable> + + <addColumn tableName="table_column"> + <column name="anonymization_configuration_id" type="BIGINT"/> + </addColumn> + <dropColumn columnName="data_privacy_category" tableName="table_column"/> + + <addUniqueConstraint columnNames="anonymization_configuration_id" constraintName="table_column_anonymization_configuration_id_key" tableName="table_column"/> + <addForeignKeyConstraint baseColumnNames="anonymization_configuration_id" baseTableName="table_column" constraintName="fk_table_column_anonymization_configuration" deferrable="false" initiallyDeferred="false" onDelete="NO ACTION" onUpdate="NO ACTION" referencedColumnNames="id" referencedTableName="anonymization_configuration" validate="true"/> + <addForeignKeyConstraint baseColumnNames="id" baseTableName="decimal_interval_border_values" constraintName="fk_decimal_interval_border_values_anonymization_configuration" deferrable="false" initiallyDeferred="false" onDelete="NO ACTION" onUpdate="NO ACTION" referencedColumnNames="id" referencedTableName="anonymization_configuration" validate="true"/> + <addForeignKeyConstraint baseColumnNames="id" baseTableName="integer_interval_border_values" constraintName="fk_integer_interval_border_values_anonymization_configuration" deferrable="false" initiallyDeferred="false" onDelete="NO ACTION" onUpdate="NO ACTION" referencedColumnNames="id" referencedTableName="anonymization_configuration" validate="true"/> + </changeSet> +</databaseChangeLog> diff --git a/backend/statistics/src/main/resources/migrations/changelog.xml b/backend/statistics/src/main/resources/migrations/changelog.xml index f2e873a689d583487335fd889b5c9bb5e129f841..ae05d631da14035bc1aea965ecff9a912e4efedb 100644 --- a/backend/statistics/src/main/resources/migrations/changelog.xml +++ b/backend/statistics/src/main/resources/migrations/changelog.xml @@ -55,4 +55,5 @@ <include file="migrations/0045_data_privacy_category.xml"/> <include file="migrations/0046_add_minBin_and_maxBin.xml"/> <include file="migrations/0047_migrate_minBin_and_maxBin.xml"/> + <include file="migrations/0048_anonymization_configuration.xml"/> </databaseChangeLog> diff --git a/backend/sti-protection/openApi.json b/backend/sti-protection/openApi.json index 969c0858e16d4acec206b75b14d1f8c9dd3c3153..8e9a37b4b15dbf69a509ead9229304ccd10a3a5e 100644 --- a/backend/sti-protection/openApi.json +++ b/backend/sti-protection/openApi.json @@ -549,15 +549,159 @@ "tags" : [ "Archiving" ] } }, + "/citizen/auth" : { + "get" : { + "operationId" : "getCitizenProcedure", + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/CitizenProcedure" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Get STI protection procedure data belonging to a user.", + "tags" : [ "Citizen" ] + } + }, + "/citizen/public/appointments" : { + "post" : { + "operationId" : "bookAppointment", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/BookAppointmentRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/BookAppointmentResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Book an appointment.", + "tags" : [ "CitizenPublic" ] + } + }, + "/citizen/public/appointments/{id}/anonymous-user" : { + "post" : { + "operationId" : "createAnonymousUser", + "parameters" : [ { + "in" : "path", + "name" : "id", + "required" : true, + "schema" : { + "type" : "string", + "format" : "uuid" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/CreateAnonymousUserRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/CreateAnonymousUserResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Create a new anonymous user identified by an access code and PIN", + "tags" : [ "CitizenPublic" ] + } + }, + "/citizen/public/appointments/{id}/confirm" : { + "post" : { + "operationId" : "confirmAppointment", + "parameters" : [ { + "in" : "path", + "name" : "id", + "required" : true, + "schema" : { + "type" : "string", + "format" : "uuid" + } + } ], + "responses" : { + "200" : { + "description" : "OK" + } + }, + "tags" : [ "CitizenPublic" ] + } + }, + "/citizen/public/appointments/{id}/personal-details" : { + "put" : { + "operationId" : "addPersonalDetails", + "parameters" : [ { + "in" : "path", + "name" : "id", + "required" : true, + "schema" : { + "type" : "string", + "format" : "uuid" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/AddPersonalDetailsRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AddPersonalDetailsResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Add personal details for an appointment", + "tags" : [ "CitizenPublic" ] + } + }, "/citizen/public/department-info" : { "get" : { "operationId" : "getDepartmentInfo", "parameters" : [ { "in" : "query", - "name" : "request", - "required" : true, + "name" : "concern", + "required" : false, "schema" : { - "$ref" : "#/components/schemas/GetDepartmentInfoRequest" + "$ref" : "#/components/schemas/Concern" } } ], "responses" : { @@ -573,7 +717,7 @@ } }, "summary" : "Get department info", - "tags" : [ "StiProtectionCitizen" ] + "tags" : [ "CitizenPublic" ] } }, "/citizen/public/free-appointments" : { @@ -584,7 +728,7 @@ "name" : "appointmentType", "required" : true, "schema" : { - "$ref" : "#/components/schemas/StiAppointmentType" + "$ref" : "#/components/schemas/Concern" } }, { "in" : "query", @@ -608,7 +752,7 @@ } }, "summary" : "Get free appointments for an appointment type.", - "tags" : [ "StiProtectionCitizen" ] + "tags" : [ "CitizenPublic" ] } }, "/citizen/public/opening-hours" : { @@ -616,10 +760,10 @@ "operationId" : "getOpeningHours", "parameters" : [ { "in" : "query", - "name" : "request", + "name" : "concern", "required" : true, "schema" : { - "$ref" : "#/components/schemas/GetOpeningHoursRequest" + "$ref" : "#/components/schemas/Concern" } } ], "responses" : { @@ -635,7 +779,7 @@ } }, "summary" : "Get opening hours", - "tags" : [ "StiProtectionCitizen" ] + "tags" : [ "CitizenPublic" ] } }, "/files/{fileId}" : { @@ -2450,24 +2594,6 @@ } }, "/sti-procedures/{id}" : { - "delete" : { - "operationId" : "deleteProcedure", - "parameters" : [ { - "in" : "path", - "name" : "id", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "responses" : { - "200" : { - "description" : "OK" - } - }, - "tags" : [ "StiProtectionProcedure" ] - }, "get" : { "operationId" : "getStiProcedure", "parameters" : [ { @@ -2605,6 +2731,27 @@ "tags" : [ "StiProtectionProcedure" ] } }, + "/sti-procedures/{id}/appointment/finalize" : { + "post" : { + "operationId" : "finalizeAppointment", + "parameters" : [ { + "in" : "path", + "name" : "id", + "required" : true, + "schema" : { + "type" : "string", + "format" : "uuid" + } + } ], + "responses" : { + "200" : { + "description" : "OK" + } + }, + "summary" : "Finalize current appointment of an STI procedure.", + "tags" : [ "StiProtectionProcedure" ] + } + }, "/sti-procedures/{id}/close" : { "put" : { "operationId" : "closeProcedure", @@ -3419,6 +3566,34 @@ "tags" : [ "TestHelper" ] } }, + "/test-helper/procedure/{procedureId}/citizen-user-id" : { + "get" : { + "operationId" : "getCitizenUserId", + "parameters" : [ { + "in" : "path", + "name" : "procedureId", + "required" : true, + "schema" : { + "type" : "string", + "format" : "uuid" + } + } ], + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "type" : "string", + "format" : "uuid" + } + } + }, + "description" : "OK" + } + }, + "tags" : [ "TestHelper" ] + } + }, "/test-helper/request-interceptor" : { "post" : { "operationId" : "interceptNextRequest", @@ -3679,6 +3854,45 @@ } } }, + "AddPersonalDetailsRequest" : { + "required" : [ "gender", "yearOfBirth" ], + "type" : "object", + "properties" : { + "countryOfBirth" : { + "$ref" : "#/components/schemas/CountryCode" + }, + "gender" : { + "$ref" : "#/components/schemas/Gender" + }, + "inGermanySince" : { + "type" : "integer", + "description" : "The year since the person has been residing in Germany.", + "format" : "int32", + "example" : 2022 + }, + "yearOfBirth" : { + "type" : "integer", + "format" : "int32" + } + } + }, + "AddPersonalDetailsResponse" : { + "required" : [ "appointmentStart", "concern", "yearOfBirth" ], + "type" : "object", + "properties" : { + "appointmentStart" : { + "type" : "string", + "format" : "date-time" + }, + "concern" : { + "$ref" : "#/components/schemas/Concern" + }, + "yearOfBirth" : { + "type" : "integer", + "format" : "int32" + } + } + }, "Address" : { "required" : [ "@type" ], "type" : "object", @@ -3749,7 +3963,7 @@ }, "AppointmentType" : { "type" : "string", - "enum" : [ "CONSULTATION", "VACCINATION", "REGULAR_EXAMINATION", "CAN_CHILD", "ENTRY_LEVEL", "SPECIAL_NEEDS", "PROOF_SUBMISSION", "HIV_STI_CONSULTATION", "SEX_WORK", "RESULTS_REVIEW", "OFFICIAL_MEDICAL_SERVICE" ] + "enum" : [ "CONSULTATION", "VACCINATION", "REGULAR_EXAMINATION", "CAN_CHILD", "ENTRY_LEVEL", "SPECIAL_NEEDS", "PROOF_SUBMISSION", "HIV_STI_CONSULTATION", "SEX_WORK", "RESULTS_REVIEW", "OFFICIAL_MEDICAL_SERVICE_SHORT", "OFFICIAL_MEDICAL_SERVICE_LONG" ] }, "AppointmentTypeConfig" : { "required" : [ "appointmentTypeDto", "id", "standardDurationInMinutes" ], @@ -3881,6 +4095,33 @@ } } }, + "BookAppointmentRequest" : { + "required" : [ "appointmentStart", "concern", "durationInMinutes" ], + "type" : "object", + "properties" : { + "appointmentStart" : { + "type" : "string", + "format" : "date-time" + }, + "concern" : { + "$ref" : "#/components/schemas/Concern" + }, + "durationInMinutes" : { + "type" : "integer", + "format" : "int32" + } + } + }, + "BookAppointmentResponse" : { + "required" : [ "procedureId" ], + "type" : "object", + "properties" : { + "procedureId" : { + "type" : "string", + "format" : "uuid" + } + } + }, "BulkUpdateProceduresArchivingRelevanceRequest" : { "required" : [ "archivingRelevance", "procedures" ], "type" : "object", @@ -3973,6 +4214,27 @@ } } }, + "CitizenProcedure" : { + "required" : [ "appointmentHistory", "concern", "person" ], + "type" : "object", + "properties" : { + "appointment" : { + "$ref" : "#/components/schemas/Appointment" + }, + "appointmentHistory" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/AppointmentHistoryEntry" + } + }, + "concern" : { + "$ref" : "#/components/schemas/Concern" + }, + "person" : { + "$ref" : "#/components/schemas/Person" + } + } + }, "Concern" : { "type" : "string", "enum" : [ "HIV_STI_CONSULTATION", "SEX_WORK" ] @@ -4152,6 +4414,37 @@ "description" : "List of country codes in ISO 3166-1 alpha-2 format. With custom extensions for stateless, non-standard countries, and unknown countries.", "enum" : [ "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW", "XK", "UNKNOWN", "STATELESS" ] }, + "CreateAnonymousUserRequest" : { + "required" : [ "pin" ], + "type" : "object", + "properties" : { + "pin" : { + "pattern" : "\\d{6}", + "type" : "string", + "description" : "The PIN for anonymous authorization.", + "example" : "654321" + } + } + }, + "CreateAnonymousUserResponse" : { + "required" : [ "accessCode", "userId" ], + "type" : "object", + "properties" : { + "accessCode" : { + "maxLength" : 17, + "minLength" : 17, + "type" : "string", + "description" : "The access code for the anonymous citizen user", + "example" : "Wzhu89yP4F728jVTT" + }, + "userId" : { + "type" : "string", + "description" : "ID of the anonymous citizen user", + "format" : "uuid", + "example" : "UUID_1" + } + } + }, "CreateAppointmentBlockGroupResponse" : { "required" : [ "appointmentBlockIds", "id" ], "type" : "object", @@ -4167,7 +4460,7 @@ "type" : "string", "description" : "Id of the AppointmentBlockGroup.", "format" : "uuid", - "example" : "UUID_1" + "example" : "UUID_2" } } }, @@ -5006,7 +5299,7 @@ "type" : "string", "description" : "Id of the AppointmentBlock.", "format" : "uuid", - "example" : "UUID_1" + "example" : "UUID_2" }, "numberOfBookedAppointments" : { "minimum" : 0, @@ -5041,7 +5334,7 @@ "type" : "string", "description" : "Id of the AppointmentBlockGroup.", "format" : "uuid", - "example" : "UUID_1" + "example" : "UUID_2" }, "location" : { "$ref" : "#/components/schemas/AppointmentLocation" @@ -5135,14 +5428,6 @@ } } }, - "GetDepartmentInfoRequest" : { - "type" : "object", - "properties" : { - "concern" : { - "$ref" : "#/components/schemas/Concern" - } - } - }, "GetDepartmentInfoResponse" : { "required" : [ "city", "country", "email", "homepage", "houseNumber", "location", "name", "phoneNumber", "postalCode", "street" ], "type" : "object", @@ -5267,7 +5552,7 @@ "type" : "string", "description" : "Id of the Facility.", "format" : "uuid", - "example" : "UUID_2" + "example" : "UUID_1" }, "name" : { "maxLength" : 300, @@ -5439,15 +5724,6 @@ } } }, - "GetOpeningHoursRequest" : { - "required" : [ "concern" ], - "type" : "object", - "properties" : { - "concern" : { - "$ref" : "#/components/schemas/Concern" - } - } - }, "GetOpeningHoursResponse" : { "required" : [ "de", "en" ], "type" : "object", @@ -5518,7 +5794,7 @@ "type" : "string", "description" : "Id of the Person.", "format" : "uuid", - "example" : "UUID_2" + "example" : "UUID_1" }, "lastName" : { "maxLength" : 120, @@ -7262,10 +7538,6 @@ "type" : "string", "enum" : [ "ASC", "DESC" ] }, - "StiAppointmentType" : { - "type" : "string", - "enum" : [ "HIV_STI_CONSULTATION", "SEX_WORK" ] - }, "StiConsultationMedicalHistory" : { "type" : "object", "allOf" : [ { @@ -7447,7 +7719,11 @@ "type" : "integer", "format" : "int32" }, - "previousFileStateId" : { + "previousFacilityFileStateId" : { + "type" : "string", + "format" : "uuid" + }, + "previousPersonFileStateId" : { "type" : "string", "format" : "uuid" }, diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/AppointmentService.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/AppointmentService.java index 219b9ff11ef5bed324ecc453ef680ac6d9297818..c5bec55f53bc6568f94e7b2458ffbb6a720c5c62 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/AppointmentService.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/AppointmentService.java @@ -76,6 +76,13 @@ public class AppointmentService { cancelAppointmentHistoryEntry(procedure); } + public void finalizeAppointment(StiProtectionProcedure procedure) { + procedure.setAppointment(null); + procedure.setCalendarEventId(null); + procedure.setUserDefinedAppointment(null); + finalizeAppointmentHistoryEntry(procedure); + } + private void bookAppointment(StiProtectionProcedure procedure, AppointmentData appointment) { AppointmentType type = appointment.appointmentType(); Instant start = appointment.appointmentStart(); @@ -204,6 +211,14 @@ public class AppointmentService { } } + private void finalizeAppointmentHistoryEntry(StiProtectionProcedure procedure) { + List<AppointmentHistoryEntry> appointmentHistory = procedure.getAppointmentHistory(); + if (!appointmentHistory.isEmpty()) { + AppointmentHistoryEntry appointmentHistoryEntry = appointmentHistory.getLast(); + appointmentHistoryEntry.setAppointmentStatus(AppointmentStatus.CLOSED); + } + } + public AppointmentHistoryEntry getOpenAppointmentHistoryEntry(StiProtectionProcedure procedure) { AppointmentHistoryEntry appointmentHistoryEntry = procedure.getAppointmentHistory().getLast(); if (appointmentHistoryEntry.getAppointmentStatus() != AppointmentStatus.OPEN) { @@ -226,4 +241,14 @@ public class AppointmentService { String timeEnd = zonedDateTimeEnd.format(timeFormatter); return "%s von %s bis %s".formatted(date, timeStart, timeEnd); } + + public void bookPublicAppointment(StiProtectionProcedure procedure, AppointmentData appointment) { + finalizeExistingAppointment(procedure); + AppointmentType type = appointment.appointmentType(); + Instant start = appointment.appointmentStart(); + Instant end = start.plus(Duration.ofMinutes(appointment.durationInMinutes())); + procedure.setUserDefinedAppointment(null); + appointmentBlockSlotUtil.updateAppointment(type, null, null, procedure, start, end); + addAppointmentHistoryEntry(procedure, appointment); + } } diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/CitizenAppointmentService.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/CitizenAppointmentService.java new file mode 100644 index 0000000000000000000000000000000000000000..76b661b52ca3ff405d557f0d1f8ba5e1c3444f96 --- /dev/null +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/CitizenAppointmentService.java @@ -0,0 +1,79 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.stiprotection; + +import de.eshg.base.citizenuser.CitizenAccessCodeUserApi; +import de.eshg.base.citizenuser.api.AddCitizenAccessCodeUserWithPinCredentialRequest; +import de.eshg.base.citizenuser.api.CitizenAccessCodeUserDto; +import de.eshg.lib.rest.oauth.client.commons.ModuleClientAuthenticator; +import de.eshg.stiprotection.persistence.data.PersonData; +import de.eshg.stiprotection.persistence.db.Concern; +import de.eshg.stiprotection.persistence.db.ProcedureExpiration; +import de.eshg.stiprotection.persistence.db.ProcedureExpirationRepository; +import de.eshg.stiprotection.persistence.db.StiProtectionProcedure; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; + +@Service +public class CitizenAppointmentService { + + private final ProcedureExpirationRepository procedureExpirationRepository; + private final CitizenAccessCodeUserApi citizenAccessCodeUserApi; + private final ModuleClientAuthenticator moduleClientAuthenticator; + private final StiProtectionProcedureService stiProtectionService; + + public CitizenAppointmentService( + ProcedureExpirationRepository procedureExpirationRepository, + CitizenAccessCodeUserApi citizenAccessCodeUserApi, + ModuleClientAuthenticator moduleClientAuthenticator, + StiProtectionProcedureService stiProtectionService) { + this.procedureExpirationRepository = procedureExpirationRepository; + this.citizenAccessCodeUserApi = citizenAccessCodeUserApi; + this.moduleClientAuthenticator = moduleClientAuthenticator; + this.stiProtectionService = stiProtectionService; + } + + public StiProtectionProcedure createProcedureWithExpiryDate(Concern concern) { + StiProtectionProcedure procedure = stiProtectionService.saveProcedure(concern); + ProcedureExpiration procedureExpiration = new ProcedureExpiration(procedure); + procedureExpirationRepository.save(procedureExpiration); + return procedure; + } + + public CitizenAccessCodeUserDto createAnonymousUser(UUID procedureId, String pin) { + StiProtectionProcedure procedure = stiProtectionService.findByExternalId(procedureId); + Assert.isNull(procedure.getAnonymousUserId(), "User already registered."); + CitizenAccessCodeUserDto user = + moduleClientAuthenticator.doWithModuleClientAuthentication( + () -> + citizenAccessCodeUserApi.addCitizenAccessCodeUserWithPinCredential( + new AddCitizenAccessCodeUserWithPinCredentialRequest(pin))); + procedure.setAnonymousUserId(user.userId()); + return user; + } + + public void deleteCitizenAccessCodeUser(UUID userId) { + this.moduleClientAuthenticator.doWithModuleClientAuthentication( + () -> citizenAccessCodeUserApi.deleteCitizenAccessCodeUser(userId)); + } + + public StiProtectionProcedure setPersonalDetails(UUID procedureId, PersonData personData) { + StiProtectionProcedure procedure = stiProtectionService.findByExternalId(procedureId); + stiProtectionService.addPerson(procedure, personData); + return procedure; + } + + public void confirmAppointment(UUID procedureId) { + StiProtectionProcedure procedure = stiProtectionService.findByExternalId(procedureId); + Assert.notNull(procedure.getAnonymousUserId(), "User registration is required"); + Assert.notNull(procedure.getAppointment(), "Appointment is required"); + Assert.notNull(procedure.getPerson(), "Personal information is required"); + procedureExpirationRepository + .findByProcedureExternalId(procedureId) + .ifPresent(procedureExpirationRepository::delete); + } +} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/CitizenController.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/CitizenController.java new file mode 100644 index 0000000000000000000000000000000000000000..5c52ea35b8cbc148b40ee7e9c56592a918ad6ee2 --- /dev/null +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/CitizenController.java @@ -0,0 +1,39 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.stiprotection; + +import de.eshg.rest.service.security.config.BaseUrls; +import de.eshg.stiprotection.api.citizen.GetCitizenProcedureResponse; +import de.eshg.stiprotection.mapper.StiProtectionProcedureMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(value = CitizenController.BASE_URL) +@Tag(name = "Citizen") +public class CitizenController { + public static final String BASE_URL = BaseUrls.StiProtection.CITIZEN_CONTROLLER; + + private final CitizenService citizenService; + + public CitizenController(CitizenService citizenService) { + this.citizenService = citizenService; + } + + @GetMapping + @Operation(summary = "Get STI protection procedure data belonging to a user.") + @Transactional(readOnly = true) + public GetCitizenProcedureResponse getCitizenProcedure(@AuthenticationPrincipal Jwt principal) { + return StiProtectionProcedureMapper.toCitizenInterfaceType( + citizenService.getProcedure(principal)); + } +} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/CitizenPublicController.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/CitizenPublicController.java new file mode 100644 index 0000000000000000000000000000000000000000..c2b638f85c9e6318d26ab548295e594fa9b82c44 --- /dev/null +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/CitizenPublicController.java @@ -0,0 +1,155 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.stiprotection; + +import de.eshg.base.citizenuser.api.CitizenAccessCodeUserDto; +import de.eshg.base.department.GetDepartmentInfoResponse; +import de.eshg.lib.appointmentblock.AppointmentBlockService; +import de.eshg.lib.appointmentblock.MappingUtil; +import de.eshg.lib.appointmentblock.api.AppointmentDto; +import de.eshg.lib.appointmentblock.api.GetFreeAppointmentsResponse; +import de.eshg.lib.appointmentblock.persistence.AppointmentType; +import de.eshg.rest.service.security.config.BaseUrls; +import de.eshg.stiprotection.api.AddPersonalDetailsRequest; +import de.eshg.stiprotection.api.AddPersonalDetailsResponse; +import de.eshg.stiprotection.api.ConcernDto; +import de.eshg.stiprotection.api.CreateAnonymousUserRequest; +import de.eshg.stiprotection.api.CreateAnonymousUserResponse; +import de.eshg.stiprotection.api.citizen.BookAppointmentRequest; +import de.eshg.stiprotection.api.citizen.BookAppointmentResponse; +import de.eshg.stiprotection.api.citizen.GetOpeningHoursResponse; +import de.eshg.stiprotection.mapper.AppointmentMapper; +import de.eshg.stiprotection.mapper.ConcernMapper; +import de.eshg.stiprotection.mapper.PersonMapper; +import de.eshg.stiprotection.persistence.data.PersonData; +import de.eshg.stiprotection.persistence.db.StiProtectionProcedure; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(path = CitizenPublicController.BASE_URL) +@Tag(name = "CitizenPublic") +public class CitizenPublicController { + + private static final Logger log = LoggerFactory.getLogger(CitizenPublicController.class); + + public static final String BASE_URL = BaseUrls.StiProtection.CITIZEN_PUBLIC_CONTROLLER; + + private final DepartmentInfoService departmentInfoService; + private final AppointmentBlockService appointmentBlockService; + private final AppointmentService appointmentService; + private final CitizenAppointmentService citizenAppointmentService; + private final Clock clock; + + public CitizenPublicController( + DepartmentInfoService departmentInfoService, + AppointmentBlockService appointmentBlockService, + AppointmentService appointmentService, + CitizenAppointmentService citizenAppointmentService, + Clock clock) { + this.departmentInfoService = departmentInfoService; + this.appointmentBlockService = appointmentBlockService; + this.appointmentService = appointmentService; + this.citizenAppointmentService = citizenAppointmentService; + this.clock = clock; + } + + @GetMapping("/department-info") + @Operation(summary = "Get department info") + @Transactional(readOnly = true) + public GetDepartmentInfoResponse getDepartmentInfo( + @RequestParam(name = "concern", required = false) ConcernDto concern) { + return departmentInfoService.getDepartmentInfo(ConcernMapper.toDatabaseType(concern)); + } + + @GetMapping("/opening-hours") + @Operation(summary = "Get opening hours") + @Transactional(readOnly = true) + public GetOpeningHoursResponse getOpeningHours( + @RequestParam(name = "concern") ConcernDto concern) { + return departmentInfoService.getOpeningHours(ConcernMapper.toDatabaseType(concern)); + } + + @Operation(summary = "Get free appointments for an appointment type.") + @GetMapping("/free-appointments") + @Transactional(readOnly = true) + public GetFreeAppointmentsResponse getFreeAppointmentsForCitizen( + @RequestParam(name = "appointmentType") @NotNull ConcernDto appointmentType, + @RequestParam(name = "earliestDate", required = false) Instant earliestDate) { + + if (earliestDate != null && earliestDate.isBefore(Instant.now(clock))) { + log.warn("Received earliestDate {} is in the past. Adjusting to current time.", earliestDate); + earliestDate = Instant.now(clock); + } + + List<AppointmentDto> appointments = + appointmentBlockService.getFreeAppointments( + earliestDate, + null, + MappingUtil.mapEnum(AppointmentType.class, appointmentType), + null, + null); + + return new GetFreeAppointmentsResponse(appointments); + } + + @PostMapping("/appointments") + @Operation(summary = "Book an appointment.") + @Transactional + public BookAppointmentResponse bookAppointment( + @Valid @RequestBody BookAppointmentRequest request) { + StiProtectionProcedure procedure = + citizenAppointmentService.createProcedureWithExpiryDate( + ConcernMapper.toDatabaseType(request.concern())); + appointmentService.bookPublicAppointment(procedure, AppointmentMapper.toDataType(request)); + return new BookAppointmentResponse(procedure.getExternalId()); + } + + @PostMapping("/appointments/{id}/anonymous-user") + @Operation(summary = "Create a new anonymous user identified by an access code and PIN") + @Transactional + public CreateAnonymousUserResponse createAnonymousUser( + @PathVariable("id") UUID procedureId, + @Valid @RequestBody CreateAnonymousUserRequest request) { + CitizenAccessCodeUserDto user = + citizenAppointmentService.createAnonymousUser(procedureId, request.pin()); + return new CreateAnonymousUserResponse(user.userId(), user.accessCode()); + } + + @PutMapping("/appointments/{id}/personal-details") + @Operation(summary = "Add personal details for an appointment") + @Transactional + public AddPersonalDetailsResponse addPersonalDetails( + @PathVariable("id") UUID procedureId, @Valid @RequestBody AddPersonalDetailsRequest request) { + PersonData personData = PersonMapper.toDataType(request); + StiProtectionProcedure procedure = + citizenAppointmentService.setPersonalDetails(procedureId, personData); + return PersonMapper.toInterfaceType(procedure); + } + + @PostMapping("/appointments/{id}/confirm") + @Transactional + public void confirmAppointment(@PathVariable("id") UUID procedureId) { + citizenAppointmentService.confirmAppointment(procedureId); + } +} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/CitizenService.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/CitizenService.java new file mode 100644 index 0000000000000000000000000000000000000000..fe0d2cdacb2ce2416a4fa97f92ff60a4a5925282 --- /dev/null +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/CitizenService.java @@ -0,0 +1,43 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.stiprotection; + +import de.eshg.rest.service.error.NotFoundException; +import de.eshg.stiprotection.persistence.data.StiProtectionProcedureData; +import de.eshg.stiprotection.persistence.db.StiProtectionProcedure; +import de.eshg.stiprotection.persistence.db.StiProtectionProcedureRepository; +import java.util.UUID; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Service; + +@Service +public class CitizenService { + + private final StiProtectionProcedureRepository repository; + + public CitizenService(StiProtectionProcedureRepository repository) { + this.repository = repository; + } + + public StiProtectionProcedureData getProcedure(Jwt principal) { + return new StiProtectionProcedureData( + findByAnonymouseUserlId(getCitizenUserId(principal)), null); + } + + private UUID getCitizenUserId(Jwt principal) { + return UUID.fromString(principal.getSubject()); + } + + private StiProtectionProcedure findByAnonymouseUserlId(UUID anonymousUserId) { + return repository + .findByAnonymousUserId(anonymousUserId) + .orElseThrow( + () -> + new NotFoundException( + "%s with given anonymous UUID not found" + .formatted(StiProtectionProcedure.class.getSimpleName()))); + } +} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/OverdueProceduresNotifier.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/OverdueProceduresNotifier.java index 889c1d444dcacba7b940a91dc7ac03a9d8b74f36..435a152870a579740e889472caa84eb7a3ee2138 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/OverdueProceduresNotifier.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/OverdueProceduresNotifier.java @@ -46,7 +46,7 @@ public class OverdueProceduresNotifier { } @Scheduled(cron = "${eshg.sti-protection.overdue-procedures.cron}") - @SchedulerLock(name = "OverdueProceduresNotifier") + @SchedulerLock(name = "OverdueProceduresNotifier", lockAtMostFor = "30m", lockAtLeastFor = "1m") @Transactional public void run() { LockAssert.assertLocked(); diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/StiProtectionCitizenController.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/StiProtectionCitizenController.java deleted file mode 100644 index 884548a77f827df639b334fc54e810a479b804df..0000000000000000000000000000000000000000 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/StiProtectionCitizenController.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package de.eshg.stiprotection; - -import de.eshg.base.department.GetDepartmentInfoResponse; -import de.eshg.lib.appointmentblock.AppointmentBlockService; -import de.eshg.lib.appointmentblock.MappingUtil; -import de.eshg.lib.appointmentblock.api.AppointmentDto; -import de.eshg.lib.appointmentblock.api.GetFreeAppointmentsResponse; -import de.eshg.lib.appointmentblock.persistence.AppointmentType; -import de.eshg.rest.service.security.config.BaseUrls; -import de.eshg.stiprotection.api.citizen.GetDepartmentInfoRequest; -import de.eshg.stiprotection.api.citizen.GetOpeningHoursRequest; -import de.eshg.stiprotection.api.citizen.GetOpeningHoursResponse; -import de.eshg.stiprotection.api.citizen.StiAppointmentTypeDto; -import de.eshg.stiprotection.mapper.ConcernMapper; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; -import java.time.Clock; -import java.time.Instant; -import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(path = StiProtectionCitizenController.BASE_URL) -@Tag(name = "StiProtectionCitizen") -public class StiProtectionCitizenController { - - private static final Logger log = LoggerFactory.getLogger(StiProtectionCitizenController.class); - - public static final String BASE_URL = BaseUrls.StiProtection.CITIZEN_PUBLIC_CONTROLLER; - - private final DepartmentInfoService departmentInfoService; - private final AppointmentBlockService appointmentBlockService; - private final Clock clock; - - public StiProtectionCitizenController( - DepartmentInfoService departmentInfoService, - AppointmentBlockService appointmentBlockService, - Clock clock) { - this.departmentInfoService = departmentInfoService; - this.appointmentBlockService = appointmentBlockService; - this.clock = clock; - } - - @GetMapping("/department-info") - @Operation(summary = "Get department info") - @Transactional(readOnly = true) - public GetDepartmentInfoResponse getDepartmentInfo( - @Valid @RequestBody GetDepartmentInfoRequest request) { - return departmentInfoService.getDepartmentInfo(ConcernMapper.toDatabaseType(request.concern())); - } - - @GetMapping("/opening-hours") - @Operation(summary = "Get opening hours") - @Transactional(readOnly = true) - public GetOpeningHoursResponse getOpeningHours( - @Valid @RequestBody GetOpeningHoursRequest request) { - return departmentInfoService.getOpeningHours(ConcernMapper.toDatabaseType(request.concern())); - } - - @Operation(summary = "Get free appointments for an appointment type.") - @GetMapping("/free-appointments") - @Transactional(readOnly = true) - public GetFreeAppointmentsResponse getFreeAppointmentsForCitizen( - @RequestParam(name = "appointmentType") @NotNull StiAppointmentTypeDto appointmentType, - @RequestParam(name = "earliestDate", required = false) Instant earliestDate) { - - if (earliestDate != null && earliestDate.isBefore(Instant.now(clock))) { - log.warn("Received earliestDate {} is in the past. Adjusting to current time.", earliestDate); - earliestDate = Instant.now(clock); - } - - List<AppointmentDto> appointments = - appointmentBlockService.getFreeAppointments( - earliestDate, - null, - MappingUtil.mapEnum(AppointmentType.class, appointmentType), - null, - null); - - return new GetFreeAppointmentsResponse(appointments); - } -} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/StiProtectionProcedureController.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/StiProtectionProcedureController.java index dacd0740614a443aff49db4bcd43448a4b79779e..5bb25ba24a069421d462fd172fb4b0e069ce5f73 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/StiProtectionProcedureController.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/StiProtectionProcedureController.java @@ -10,6 +10,7 @@ import static de.eshg.stiprotection.persistence.db.StiProtectionSystemProgressEn import de.eshg.api.commons.InlineParameterObject; import de.eshg.lib.auditlog.AuditLogger; import de.eshg.lib.procedure.domain.model.Pdf; +import de.eshg.persistence.IntentionalWritingTransaction; import de.eshg.rest.service.error.BadRequestException; import de.eshg.rest.service.security.CurrentUserHelper; import de.eshg.rest.service.security.config.BaseUrls; @@ -52,7 +53,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -110,6 +110,7 @@ public class StiProtectionProcedureController { @GetMapping("/{id}") @Operation(summary = "Get STI protection procedure by id.") @Transactional + @IntentionalWritingTransaction(reason = "Audit logging") public GetProcedureResponse getStiProcedure(@PathVariable("id") UUID procedureId) { auditLogger.log( "Vorgangsbearbeitung", @@ -125,6 +126,7 @@ public class StiProtectionProcedureController { @GetMapping @Transactional + @IntentionalWritingTransaction(reason = "Audit logging") @Operation(summary = "Get sorted and paginated STI procedures.") public GetProceduresOverviewResponse getStiProcedures( @Valid @ParameterObject @InlineParameterObject @@ -191,6 +193,20 @@ public class StiProtectionProcedureController { procedureId, StiProtectionSystemProgressEntryType.APPOINTMENT_CANCELLED); } + @PostMapping("/{id}/appointment/finalize") + @Operation(summary = "Finalize current appointment of an STI procedure.") + @Transactional + public void finalizeAppointment(@PathVariable("id") UUID procedureId) { + StiProtectionProcedure procedure = procedureFinder.findByExternalId(procedureId); + if (procedure.getAppointment() == null && procedure.getUserDefinedAppointment() == null) { + throw new BadRequestException( + "Procedure %s has no outstanding appointment".formatted(procedure.getExternalId())); + } + appointmentService.finalizeAppointment(procedure); + progressEntryUtil.addProgressEntry( + procedureId, StiProtectionSystemProgressEntryType.APPOINTMENT_FINALIZED); + } + @PutMapping("/{id}/close") @Operation(summary = "Close an STI procedure.") @Transactional @@ -243,13 +259,6 @@ public class StiProtectionProcedureController { stiProtectionService.verifyAnonymousUserPin(procedureId, pin); } - @DeleteMapping("/{id}") - @Transactional - public void deleteProcedure(@PathVariable("id") UUID procedureId) { - procedureDeletionService.deleteAndWriteToCemetery( - procedureFinder.findByExternalId(procedureId)); - } - @PostMapping("/{id}/follow-up") @Operation(summary = "Create an STI follow-up procedure.") @Transactional diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/StiProtectionProcedureService.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/StiProtectionProcedureService.java index 85c5b6ee1163649fe258b91549f6312f3f0cf4a8..dd7001ce368f425be80f46633cb1d2d00d939c9b 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/StiProtectionProcedureService.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/StiProtectionProcedureService.java @@ -24,7 +24,6 @@ import de.eshg.lib.document.generator.department.DepartmentLogo; import de.eshg.lib.procedure.domain.model.Pdf; import de.eshg.lib.procedure.domain.model.PersonType; import de.eshg.lib.procedure.domain.model.ProcedureStatus; -import de.eshg.lib.procedure.domain.model.ProcedureType; import de.eshg.lib.procedure.domain.model.Procedure_; import de.eshg.lib.procedure.domain.model.RelatedPerson; import de.eshg.lib.procedure.domain.model.TaskStatus; @@ -51,13 +50,12 @@ import de.eshg.stiprotection.persistence.db.StiProtectionProcedureRepository; import de.eshg.stiprotection.persistence.db.StiProtectionProcedure_; import de.eshg.stiprotection.persistence.db.StiProtectionTask; import de.eshg.stiprotection.util.ProgressEntryUtil; -import jakarta.persistence.criteria.Join; -import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.Path; import jakarta.persistence.criteria.Root; import java.time.Clock; import java.time.Instant; import java.util.List; +import java.util.Optional; import java.util.UUID; import org.apache.commons.lang3.RandomStringUtils; import org.springframework.data.domain.Page; @@ -99,14 +97,16 @@ public class StiProtectionProcedureService { } public StiProtectionProcedure createProcedure(Concern concern) { - StiProtectionProcedure procedure = new StiProtectionProcedure(); - procedure.setProcedureType(ProcedureType.STI_PROTECTION); - procedure.updateProcedureStatus(ProcedureStatus.OPEN, clock, auditLogger); - procedure.setConcern(concern); + StiProtectionProcedure procedure = + StiProtectionProcedure.newProcedure(concern, clock, auditLogger); procedure.addTask(createTask()); return repository.save(procedure); } + public StiProtectionProcedure saveProcedure(Concern concern) { + return repository.save(StiProtectionProcedure.newProcedure(concern, clock, auditLogger)); + } + public void addPerson(StiProtectionProcedure procedure, PersonData personData) { Person person = PersonMapper.toDatabaseType(personData); person.setCentralFileStateId(createUniqueDummyCentralFileStateId()); @@ -166,9 +166,6 @@ public class StiProtectionProcedureService { GetStiProtectionProceduresSortOrderDto sortOrder, GetStiProtectionProceduresSortByDto sortBy) { return (root, query, criteriaBuilder) -> { - Join<StiProtectionProcedure, Person> psJoin = - root.join(Procedure_.RELATED_PERSONS, JoinType.INNER); - Path<?> sortProperty = getSortProperty(sortBy, root); if (sortOrder == ASC) { @@ -190,7 +187,7 @@ public class StiProtectionProcedureService { } private StiProtectionProcedureData toProcedureData(StiProtectionProcedure procedure) { - UUID anonymousUserId = procedure.getPerson().getAnonymousUserId(); + UUID anonymousUserId = procedure.getAnonymousUserId(); String accessCode = anonymousUserId != null ? citizenAccessCodeUserApi.getCitizenAccessCodeUser(anonymousUserId).accessCode() @@ -252,7 +249,7 @@ public class StiProtectionProcedureService { } private String getAccessCode(StiProtectionProcedureData procedure) { - UUID anonymousUserId = procedure.person().getAnonymousUserId(); + UUID anonymousUserId = procedure.anonymousUserId(); if (anonymousUserId == null) { throw new BadRequestException("Anonymous user not registered"); } @@ -269,34 +266,38 @@ public class StiProtectionProcedureService { } public void registerAnonymousUser(StiProtectionProcedure procedure, String pin) { - UUID anonymousUserId = procedure.getPerson().getAnonymousUserId(); + UUID anonymousUserId = procedure.getAnonymousUserId(); if (anonymousUserId != null) { throw new BadRequestException("User already registered."); } CitizenAccessCodeUserDto user = citizenAccessCodeUserApi.addCitizenAccessCodeUserWithPinCredential( new AddCitizenAccessCodeUserWithPinCredentialRequest(pin)); - procedure.getPerson().setAnonymousUserId(user.userId()); + procedure.setAnonymousUserId(user.userId()); } public void deleteAnonymousUser(StiProtectionProcedure procedure) { - Person person = procedure.getPerson(); - UUID anonymousUserId = person.getAnonymousUserId(); + UUID anonymousUserId = procedure.getAnonymousUserId(); if (anonymousUserId != null) { citizenAccessCodeUserApi.deleteCitizenAccessCodeUser(anonymousUserId); - person.setAnonymousUserId(null); + procedure.setAnonymousUserId(null); } } public void verifyAnonymousUserPin(UUID procedureId, String pin) { StiProtectionProcedure procedure = procedureFinder.findByExternalId(procedureId); - Person person = procedure.getPerson(); try { + UUID userId = + Optional.ofNullable(procedure.getAnonymousUserId()) + .orElseThrow(() -> new BadRequestException("Procedure has no user")); citizenAccessCodeUserApi.verifyCitizenAccessCodeUserCredentials( - person.getAnonymousUserId(), - new VerifyCitizenAccessCodeUserCredentialsRequest(CredentialTypeDto.PIN, pin)); + userId, new VerifyCitizenAccessCodeUserCredentialsRequest(CredentialTypeDto.PIN, pin)); } catch (HttpClientErrorException.BadRequest e) { throw new BadRequestException("Invalid credentials"); } } + + public StiProtectionProcedure findByExternalId(UUID procedureId) { + return procedureFinder.findByExternalId(procedureId); + } } diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/UnconfirmedAppointmentsRemover.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/UnconfirmedAppointmentsRemover.java new file mode 100644 index 0000000000000000000000000000000000000000..4fe0710e55a19db7a11b87055a62eec25066c9b0 --- /dev/null +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/UnconfirmedAppointmentsRemover.java @@ -0,0 +1,110 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.stiprotection; + +import de.eshg.stiprotection.persistence.db.ProcedureExpiration; +import de.eshg.stiprotection.persistence.db.ProcedureExpirationRepository; +import de.eshg.stiprotection.persistence.db.StiProtectionProcedure; +import de.eshg.stiprotection.persistence.db.StiProtectionProcedureRepository; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import net.javacrumbs.shedlock.core.LockAssert; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class UnconfirmedAppointmentsRemover { + + private static final Logger log = LoggerFactory.getLogger(UnconfirmedAppointmentsRemover.class); + + private final ProcedureExpirationRepository procedureExpirationRepository; + private final StiProtectionProcedureRepository procedureRepository; + private final CitizenAppointmentService citizenAppointmentService; + private final Clock clock; + + @Value("${eshg.sti-protection.unconfirmed-appointments.expire-after:1h}") + private Duration expireAfter; + + @Value("${eshg.sti-protection.unconfirmed-appointments.page-size:100}") + private int pageSize; + + public UnconfirmedAppointmentsRemover( + ProcedureExpirationRepository procedureExpirationRepository, + StiProtectionProcedureRepository procedureRepository, + CitizenAppointmentService citizenAppointmentService, + Clock clock) { + this.procedureExpirationRepository = procedureExpirationRepository; + this.procedureRepository = procedureRepository; + this.citizenAppointmentService = citizenAppointmentService; + this.clock = clock; + } + + @Scheduled(cron = "${eshg.sti-protection.unconfirmed-appointments.cron:@hourly}") + @SchedulerLock( + name = "UnconfirmedAppointmentsRemover", + lockAtMostFor = "30m", + lockAtLeastFor = "1m") + @Transactional + public void run() { + LockAssert.assertLocked(); + remove(); + } + + void remove() { + Instant retentionTime = Instant.now(clock).minus(expireAfter); + log.debug( + "expireAfter = {}, retentionTime = {}, pageSize = {}", + expireAfter, + retentionTime, + pageSize); + Page<ProcedureExpiration> expiredPage; + int pageNumber = 0; + do { + expiredPage = + procedureExpirationRepository.findByCreatedAtBefore( + retentionTime, PageRequest.of(pageNumber, pageSize)); + List<ProcedureExpiration> expired = expiredPage.getContent(); + log.debug("{} expired procedures found in batch", expired.size()); + for (ProcedureExpiration procedureExpiration : expired) { + UUID procedureExternalId = procedureExpiration.getProcedureExternalId(); + log.debug("deleting expired procedure = {}", procedureExternalId); + try { + procedureRepository.findByExternalId(procedureExternalId).ifPresent(this::removeExpired); + procedureExpirationRepository.delete(procedureExpiration); + } catch (RuntimeException e) { + log.error("Error deleting procedure with ID {}", procedureExternalId, e); + } + } + pageNumber++; + } while (!expiredPage.isLast()); + } + + private void removeExpired(StiProtectionProcedure procedure) { + UUID anonymousUserId = procedure.getAnonymousUserId(); + if (anonymousUserId != null) { + try { + citizenAppointmentService.deleteCitizenAccessCodeUser(anonymousUserId); + } catch (RuntimeException e) { + log.warn("Error deleting user with ID {}", anonymousUserId, e); + } + } + procedureRepository.delete(procedure); + } + + public Duration getExpireAfter() { + return expireAfter; + } +} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/WaitingRoomService.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/WaitingRoomService.java index b4429f7d9a6947b1a7b87258c7753543ac7a0ded..a38d79b5e2c49dbd288d7c10e9bf7d4a715cb77d 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/WaitingRoomService.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/WaitingRoomService.java @@ -7,6 +7,7 @@ package de.eshg.stiprotection; import de.eshg.base.SortDirection; import de.eshg.base.citizenuser.CitizenAccessCodeUserApi; +import de.eshg.base.citizenuser.api.CitizenAccessCodeUserDto; import de.eshg.stiprotection.api.waitingroom.WaitingRoomProcedurePaginationAndSortParameters; import de.eshg.stiprotection.api.waitingroom.WaitingRoomSortKey; import de.eshg.stiprotection.mapper.waitingroom.WaitingRoomMapper; @@ -16,6 +17,7 @@ import de.eshg.stiprotection.persistence.db.waitingroom.WaitingRoom; import de.eshg.stiprotection.persistence.db.waitingroom.WaitingRoomSpecification; import jakarta.validation.Valid; import java.util.Objects; +import java.util.Optional; import java.util.UUID; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -51,9 +53,10 @@ public class WaitingRoomService { } public String getAccessCode(StiProtectionProcedure procedure) { - return citizenAccessCodeUserApi - .getCitizenAccessCodeUser(procedure.getPerson().getAnonymousUserId()) - .accessCode(); + return Optional.ofNullable(procedure.getAnonymousUserId()) + .map(citizenAccessCodeUserApi::getCitizenAccessCodeUser) + .map(CitizenAccessCodeUserDto::accessCode) + .orElse(null); } public WaitingRoom getOrCreateWaitingRoom(UUID procedureId) { diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/AddPersonalDetailsRequest.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/AddPersonalDetailsRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..a4015b49ba685a84c91c868dcaa7011dbe8bc2f3 --- /dev/null +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/AddPersonalDetailsRequest.java @@ -0,0 +1,39 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.stiprotection.api; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import de.eshg.base.GenderDto; +import de.eshg.lib.common.CountryCode; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Past; +import jakarta.validation.constraints.PastOrPresent; +import java.time.Year; + +public record AddPersonalDetailsRequest( + @NotNull GenderDto gender, + @NotNull @Past @Schema(type = "integer") Year yearOfBirth, + CountryCode countryOfBirth, + @Schema( + type = "integer", + description = "The year since the person has been residing in Germany.", + example = "2022") + @PastOrPresent + Year inGermanySince) + implements PersonalDetails { + + @AssertTrue(message = "The year of birth must be prior to the date of residence in Germany.") + @JsonIgnore + @SuppressWarnings("unused") + public boolean isInGermanySinceValid() { + if (inGermanySince == null) { + return true; + } + return yearOfBirth.isBefore(inGermanySince); + } +} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/AddPersonalDetailsResponse.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/AddPersonalDetailsResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..8b046c87894cc3e9c0f8215819e287a3f17a4f8f --- /dev/null +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/AddPersonalDetailsResponse.java @@ -0,0 +1,17 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.stiprotection.api; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Past; +import java.time.Instant; +import java.time.Year; + +public record AddPersonalDetailsResponse( + @NotNull ConcernDto concern, + @NotNull Instant appointmentStart, + @NotNull @Past @Schema(type = "integer") Year yearOfBirth) {} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/CreateAnonymousUserRequest.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/CreateAnonymousUserRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..2a79104b52be8478ab1dd234fd152b2bf761186f --- /dev/null +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/CreateAnonymousUserRequest.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.stiprotection.api; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +public record CreateAnonymousUserRequest( + @Schema(description = "The PIN for anonymous authorization.", example = "654321") + @NotNull + @Pattern(regexp = "\\d{6}", message = "The PIN must contain exactly 6 digits") + String pin) {} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/CreateAnonymousUserResponse.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/CreateAnonymousUserResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..35caf078031dc3ed10dcdd8b16d1150b5fc642ef --- /dev/null +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/CreateAnonymousUserResponse.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.stiprotection.api; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.UUID; + +public record CreateAnonymousUserResponse( + @Schema( + description = "ID of the anonymous citizen user", + example = "ae9831d4-dc25-48d8-9bfe-4c0b54bfb2c1") + @NotNull + UUID userId, + @Schema( + description = "The access code for the anonymous citizen user", + example = "Wzhu89yP4F728jVTT") + @NotNull + @Size(min = 17, max = 17) + String accessCode) {} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/CreateProcedureRequest.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/CreateProcedureRequest.java index fed36deb59d8260d2c4d65577f18f248295058d1..96a3014a0361edb828cf7522db28a55b81a299ae 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/CreateProcedureRequest.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/CreateProcedureRequest.java @@ -30,7 +30,8 @@ public record CreateProcedureRequest( Year inGermanySince, @NotNull AppointmentBookingTypeDto appointmentBookingType, @NotNull Instant appointmentStart, - @NotNull @Positive Integer durationInMinutes) { + @NotNull @Positive Integer durationInMinutes) + implements PersonalDetails { @AssertTrue(message = "The year of birth must be prior to the date of residence in Germany.") @JsonIgnore diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/PersonalDetails.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/PersonalDetails.java new file mode 100644 index 0000000000000000000000000000000000000000..58aefa8883d3407317a9cf8834ef4b87798f3dca --- /dev/null +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/PersonalDetails.java @@ -0,0 +1,21 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.stiprotection.api; + +import de.eshg.base.GenderDto; +import de.eshg.lib.common.CountryCode; +import java.time.Year; + +public interface PersonalDetails { + + GenderDto gender(); + + Year yearOfBirth(); + + CountryCode countryOfBirth(); + + Year inGermanySince(); +} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/BookAppointmentRequest.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/BookAppointmentRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..4b1fb015465b6847a30134d6a45ddcb907cd7ba9 --- /dev/null +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/BookAppointmentRequest.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.stiprotection.api.citizen; + +import de.eshg.stiprotection.api.ConcernDto; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.time.Instant; + +public record BookAppointmentRequest( + @NotNull ConcernDto concern, + @NotNull Instant appointmentStart, + @NotNull @Positive Integer durationInMinutes) {} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/GetOpeningHoursRequest.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/BookAppointmentResponse.java similarity index 60% rename from backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/GetOpeningHoursRequest.java rename to backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/BookAppointmentResponse.java index 3ad4a519756bd4a33d25976228eb94dd1bf08683..10cf238b20435877ef77a2e5a83da9be6af96254 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/GetOpeningHoursRequest.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/BookAppointmentResponse.java @@ -5,7 +5,7 @@ package de.eshg.stiprotection.api.citizen; -import de.eshg.stiprotection.api.ConcernDto; import jakarta.validation.constraints.NotNull; +import java.util.UUID; -public record GetOpeningHoursRequest(@NotNull ConcernDto concern) {} +public record BookAppointmentResponse(@NotNull UUID procedureId) {} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/GetCitizenProcedureResponse.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/GetCitizenProcedureResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..e1b042848bde9b9f39c069d145d75b9c56eca814 --- /dev/null +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/GetCitizenProcedureResponse.java @@ -0,0 +1,22 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.stiprotection.api.citizen; + +import de.eshg.lib.appointmentblock.api.AppointmentDto; +import de.eshg.stiprotection.api.AppointmentHistoryEntryDto; +import de.eshg.stiprotection.api.ConcernDto; +import de.eshg.stiprotection.api.PersonDto; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +@Schema(name = "CitizenProcedure") +public record GetCitizenProcedureResponse( + @NotNull ConcernDto concern, + @NotNull @Valid PersonDto person, + @Valid AppointmentDto appointment, + @NotNull @Valid List<AppointmentHistoryEntryDto> appointmentHistory) {} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/GetDepartmentInfoRequest.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/GetDepartmentInfoRequest.java deleted file mode 100644 index 955279dc5f144d64fb38f313dbf32c2e7afeafdb..0000000000000000000000000000000000000000 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/GetDepartmentInfoRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package de.eshg.stiprotection.api.citizen; - -import de.eshg.stiprotection.api.ConcernDto; - -public record GetDepartmentInfoRequest(ConcernDto concern) {} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/StiAppointmentTypeDto.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/StiAppointmentTypeDto.java deleted file mode 100644 index e7cd6c3da9257a4df68c653cbfa5af594e0e43c2..0000000000000000000000000000000000000000 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/api/citizen/StiAppointmentTypeDto.java +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package de.eshg.stiprotection.api.citizen; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(name = "StiAppointmentType") -public enum StiAppointmentTypeDto { - HIV_STI_CONSULTATION, - SEX_WORK -} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/mapper/AppointmentMapper.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/mapper/AppointmentMapper.java index 9f1506a1e6ea3e3d066015148ecdf46e312c041e..2ee4b3f9b0e0f5c1e22c0654fc2960c6d37022dd 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/mapper/AppointmentMapper.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/mapper/AppointmentMapper.java @@ -6,6 +6,7 @@ package de.eshg.stiprotection.mapper; import de.eshg.lib.appointmentblock.AppointmentTypeMapper; +import de.eshg.lib.appointmentblock.MappingUtil; import de.eshg.lib.appointmentblock.api.AppointmentDto; import de.eshg.lib.appointmentblock.persistence.AppointmentType; import de.eshg.lib.appointmentblock.persistence.entity.Appointment; @@ -13,6 +14,7 @@ import de.eshg.stiprotection.api.CreateAppointmentRequest; import de.eshg.stiprotection.api.CreateFollowUpProcedureRequest; import de.eshg.stiprotection.api.CreateProcedureRequest; import de.eshg.stiprotection.api.UpdateAppointmentRequest; +import de.eshg.stiprotection.api.citizen.BookAppointmentRequest; import de.eshg.stiprotection.persistence.data.AppointmentBookingType; import de.eshg.stiprotection.persistence.data.AppointmentData; import de.eshg.stiprotection.persistence.db.UserDefinedAppointment; @@ -76,4 +78,12 @@ public class AppointmentMapper { request.appointmentStart(), request.durationInMinutes()); } + + public static AppointmentData toDataType(BookAppointmentRequest request) { + return new AppointmentData( + AppointmentBookingType.APPOINTMENT_BLOCK, + MappingUtil.mapEnum(AppointmentType.class, request.concern()), + request.appointmentStart(), + request.durationInMinutes()); + } } diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/mapper/PersonMapper.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/mapper/PersonMapper.java index 08058d4bb6d3936c1eae3ad426fc259cf2bb9ed3..3bace5c67b183d4aa61dece90c73c2797abcc556 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/mapper/PersonMapper.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/mapper/PersonMapper.java @@ -5,11 +5,17 @@ package de.eshg.stiprotection.mapper; -import de.eshg.stiprotection.api.CreateProcedureRequest; +import de.eshg.lib.appointmentblock.MappingUtil; +import de.eshg.stiprotection.api.AddPersonalDetailsResponse; +import de.eshg.stiprotection.api.ConcernDto; import de.eshg.stiprotection.api.PersonDto; +import de.eshg.stiprotection.api.PersonalDetails; import de.eshg.stiprotection.api.UpdatePersonDetailsRequest; import de.eshg.stiprotection.persistence.data.PersonData; import de.eshg.stiprotection.persistence.db.Person; +import de.eshg.stiprotection.persistence.db.StiProtectionProcedure; +import java.time.Instant; +import java.time.Year; public class PersonMapper { @@ -25,7 +31,7 @@ public class PersonMapper { accessCode); } - public static PersonData toDataType(CreateProcedureRequest request) { + public static PersonData toDataType(PersonalDetails request) { return new PersonData( GenderMapper.toDatabaseType(request.gender()), request.yearOfBirth(), @@ -57,4 +63,11 @@ public class PersonMapper { person.setInGermanySince(data.inGermanySince()); return person; } + + public static AddPersonalDetailsResponse toInterfaceType(StiProtectionProcedure procedure) { + ConcernDto concern = MappingUtil.mapEnum(ConcernDto.class, procedure.getConcern()); + Instant appointmentStart = procedure.getAppointmentStart(); + Year yearOfBirth = procedure.getPerson().getYearOfBirth(); + return new AddPersonalDetailsResponse(concern, appointmentStart, yearOfBirth); + } } diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/mapper/StiProtectionProcedureMapper.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/mapper/StiProtectionProcedureMapper.java index 2759c4031cf0d3950803b33eafa501fd045c1731..95aeca2f7aba8424ca3f0c2bd9142fd5844a8a4e 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/mapper/StiProtectionProcedureMapper.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/mapper/StiProtectionProcedureMapper.java @@ -9,6 +9,7 @@ import de.eshg.lib.procedure.mapping.ProcedureMapper; import de.eshg.stiprotection.api.CreateProcedureResponse; import de.eshg.stiprotection.api.GetProcedureResponse; import de.eshg.stiprotection.api.StiProtectionProcedureOverviewDto; +import de.eshg.stiprotection.api.citizen.GetCitizenProcedureResponse; import de.eshg.stiprotection.mapper.waitingroom.WaitingRoomMapper; import de.eshg.stiprotection.persistence.data.StiProtectionProcedureData; import de.eshg.stiprotection.persistence.db.StiProtectionProcedure; @@ -38,6 +39,16 @@ public class StiProtectionProcedureMapper { procedureData.sampleBarCode()); } + public static GetCitizenProcedureResponse toCitizenInterfaceType( + StiProtectionProcedureData procedureData) { + return new GetCitizenProcedureResponse( + ConcernMapper.toInterfaceType(procedureData.concern()), + PersonMapper.toInterfaceType(procedureData.person(), procedureData.accessCode()), + AppointmentMapper.toInterfaceType( + procedureData.appointment(), procedureData.userDefinedAppointment()), + AppointmentHistoryMapper.toInterfaceType(procedureData.appointmentHistory())); + } + public static StiProtectionProcedureOverviewDto toOverviewType( StiProtectionProcedureData procedureData) { return new StiProtectionProcedureOverviewDto( diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/data/StiProtectionProcedureData.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/data/StiProtectionProcedureData.java index 241e07493d2cba4832e40f0f5479c0b08fa7449c..1f7c33aa47b984d472762cec3abd162d5eb6bb74 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/data/StiProtectionProcedureData.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/data/StiProtectionProcedureData.java @@ -34,6 +34,10 @@ public record StiProtectionProcedureData(StiProtectionProcedure procedure, Strin return procedure.getConcern(); } + public UUID anonymousUserId() { + return procedure.getAnonymousUserId(); + } + public Boolean isFollowUp() { return procedure.isFollowUp(); } diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/Person.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/Person.java index f2d13d541831b2e73e65ef8fa94e9992dcebc735..443e1b8946b90e143a3cf31ef96c502c50bc5fe9 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/Person.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/Person.java @@ -14,7 +14,6 @@ import jakarta.persistence.Entity; import jakarta.persistence.Index; import jakarta.persistence.Table; import java.time.Year; -import java.util.UUID; import org.hibernate.annotations.JdbcType; import org.hibernate.dialect.PostgreSQLEnumJdbcType; @@ -38,9 +37,6 @@ public class Person extends RelatedPerson<StiProtectionProcedure> { @DataSensitivity(SensitivityLevel.UNDEFINED) private Year inGermanySince; - @DataSensitivity(SensitivityLevel.UNDEFINED) - private UUID anonymousUserId; - public Gender getGender() { return gender; } @@ -72,12 +68,4 @@ public class Person extends RelatedPerson<StiProtectionProcedure> { public void setInGermanySince(Year inGermanySince) { this.inGermanySince = inGermanySince; } - - public void setAnonymousUserId(UUID anonymousUserId) { - this.anonymousUserId = anonymousUserId; - } - - public UUID getAnonymousUserId() { - return anonymousUserId; - } } diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/ProcedureExpiration.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/ProcedureExpiration.java new file mode 100644 index 0000000000000000000000000000000000000000..9bbfbbaa082ff8696686bc2638bcd8df5c7a6004 --- /dev/null +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/ProcedureExpiration.java @@ -0,0 +1,60 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.stiprotection.persistence.db; + +import de.eshg.domain.model.BaseEntity; +import de.eshg.lib.common.DataSensitivity; +import de.eshg.lib.common.SensitivityLevel; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.util.Assert; + +@Entity +@DataSensitivity(SensitivityLevel.PUBLIC) +@Table( + indexes = { + @Index(name = "idx_procedure_expiration_created_at", columnList = "created_at"), + @Index(name = "idx_procedure_expiration_external_id", columnList = "procedure_external_id") + }) +@EntityListeners(AuditingEntityListener.class) +public class ProcedureExpiration extends BaseEntity { + + @Column(nullable = false) + @CreatedDate + private Instant createdAt; + + private UUID procedureExternalId; + + public ProcedureExpiration() {} + + public ProcedureExpiration(StiProtectionProcedure procedure) { + Assert.notNull(procedure, "StiProtectionProcedure must not be null"); + procedureExternalId = procedure.getExternalId(); + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public UUID getProcedureExternalId() { + return procedureExternalId; + } + + public void setProcedureExternalId(UUID procedureExternalId) { + this.procedureExternalId = procedureExternalId; + } +} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/ProcedureExpirationRepository.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/ProcedureExpirationRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..1ce99cac1e838bca59baf6c4c69b4c6f29cf6332 --- /dev/null +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/ProcedureExpirationRepository.java @@ -0,0 +1,20 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.stiprotection.persistence.db; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProcedureExpirationRepository extends JpaRepository<ProcedureExpiration, Long> { + + Page<ProcedureExpiration> findByCreatedAtBefore(Instant retentionTime, Pageable page); + + Optional<ProcedureExpiration> findByProcedureExternalId(UUID procedureExternalId); +} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/StiProtectionProcedure.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/StiProtectionProcedure.java index df22cc43473cbd1ef8d9779068b82db33a8edcbd..c30a0614db21a12f7813e636a60de459aa4520f6 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/StiProtectionProcedure.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/StiProtectionProcedure.java @@ -9,9 +9,12 @@ import static java.lang.Boolean.TRUE; import de.eshg.lib.appointmentblock.EntityWithAppointment; import de.eshg.lib.appointmentblock.persistence.entity.Appointment; +import de.eshg.lib.auditlog.AuditLogger; import de.eshg.lib.common.DataSensitivity; import de.eshg.lib.common.SensitivityLevel; import de.eshg.lib.procedure.domain.model.Procedure; +import de.eshg.lib.procedure.domain.model.ProcedureStatus; +import de.eshg.lib.procedure.domain.model.ProcedureType; import de.eshg.stiprotection.persistence.db.consultation.Consultation; import de.eshg.stiprotection.persistence.db.consultation.Consultation_; import de.eshg.stiprotection.persistence.db.diagnosis.Diagnosis; @@ -37,6 +40,7 @@ import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; import jakarta.persistence.Transient; +import java.time.Clock; import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -63,6 +67,9 @@ public class StiProtectionProcedure @Column(nullable = false) private Concern concern; + @DataSensitivity(SensitivityLevel.UNDEFINED) + private UUID anonymousUserId; + @DataSensitivity(SensitivityLevel.PSEUDONYMIZED) @Column(nullable = false) private Boolean isFollowUp = false; @@ -150,6 +157,15 @@ public class StiProtectionProcedure @DataSensitivity(SensitivityLevel.SENSITIVE) private Instant appointmentStart; + public static StiProtectionProcedure newProcedure( + Concern concern, Clock clock, AuditLogger auditLogger) { + StiProtectionProcedure procedure = new StiProtectionProcedure(); + procedure.setProcedureType(ProcedureType.STI_PROTECTION); + procedure.updateProcedureStatus(ProcedureStatus.OPEN, clock, auditLogger); + procedure.setConcern(concern); + return procedure; + } + @Transient public Person getPerson() { Assert.isTrue(getRelatedPersons().size() == 1, "There should be exactly one related person"); @@ -164,6 +180,14 @@ public class StiProtectionProcedure this.concern = concern; } + public UUID getAnonymousUserId() { + return anonymousUserId; + } + + public void setAnonymousUserId(UUID anonymousUserId) { + this.anonymousUserId = anonymousUserId; + } + public Boolean isFollowUp() { return isFollowUp; } diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/StiProtectionProcedureRepository.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/StiProtectionProcedureRepository.java index 7143dd2250f46bb0358e47cf43df5a69e029b734..36cbc53ced18c7e706be4bee2902f7c939519500 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/StiProtectionProcedureRepository.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/StiProtectionProcedureRepository.java @@ -9,6 +9,7 @@ import de.eshg.lib.procedure.domain.repository.ProcedureRepository; import java.time.Instant; import java.util.Collection; import java.util.List; +import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; @@ -23,4 +24,6 @@ public interface StiProtectionProcedureRepository List<StiProtectionProcedure> findAllByCalendarEventIdOrderById(Collection<UUID> calendarEventIds); List<StiProtectionProcedure> findByCreatedAtBefore(Instant overdueDate); + + Optional<StiProtectionProcedure> findByAnonymousUserId(UUID anonymousUserId); } diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/StiProtectionSystemProgressEntryType.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/StiProtectionSystemProgressEntryType.java index 4fd39fb42ba7752094a7e48312c293f442700cba..dcfa3d50be091c175e7059f2b51f39a9fa2dcb5e 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/StiProtectionSystemProgressEntryType.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/StiProtectionSystemProgressEntryType.java @@ -12,6 +12,7 @@ public enum StiProtectionSystemProgressEntryType { LABORATORY_TEST_EXAMINATION_UPDATED("Die Labortests wurden aktualisiert."), APPOINTMENT_REBOOKED("Der Termin wurde verschoben auf den %s."), APPOINTMENT_CANCELLED("Ein Termin wurde storniert."), + APPOINTMENT_FINALIZED("Ein Termin wurde als abgeschlossen markiert."), MEDICAL_HISTORY_UPDATED("Der Anamnesebogen wurde aktualisiert."), CONSULTATION_UPDATED("Die Konsultation wurde aktualisiert."), DIAGNOSIS_UPDATED("Die Diagnose wurde aktualisiert."), diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/examination/RapidTestExamination.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/examination/RapidTestExamination.java index 5cdab2991f15d5be7ce5c8e212cad261bd4b9339..85eb0d9d6b7bc35afc67673731535d9b933308c4 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/examination/RapidTestExamination.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/persistence/db/examination/RapidTestExamination.java @@ -62,8 +62,8 @@ public class RapidTestExamination extends GenericEntity<Long> { private RapidTestData hivData; @AttributeOverrides({ - @AttributeOverride(name = "number", column = @Column(name = "syphillis_number")), - @AttributeOverride(name = "result", column = @Column(name = "syphillis_result")), + @AttributeOverride(name = "number", column = @Column(name = "syphilis_number")), + @AttributeOverride(name = "result", column = @Column(name = "syphilis_result")), }) @Embedded private RapidTestData syphilisData; diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/testhelper/StiProtectionTestHelperController.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/testhelper/StiProtectionTestHelperController.java index 324abfae0d9b964fa74e2b1d6dcd60677e3915e4..3ce4b43f3bd774e9c050e9d27ff57d257d891d8e 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/testhelper/StiProtectionTestHelperController.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/testhelper/StiProtectionTestHelperController.java @@ -15,13 +15,18 @@ import de.eshg.stiprotection.api.TextTemplatePopulationRequest; import de.eshg.stiprotection.api.TextTemplatePopulationResponse; import de.eshg.stiprotection.api.texttemplate.TextTemplateDto; import de.eshg.testhelper.ConditionalOnTestHelperEnabled; +import de.eshg.testhelper.DefaultTestHelperService; import de.eshg.testhelper.TestHelperController; import de.eshg.testhelper.environment.EnvironmentConfig; import de.eshg.testhelper.population.ListWithTotalNumber; import jakarta.validation.Valid; +import java.util.UUID; import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.service.annotation.GetExchange; import org.springframework.web.service.annotation.PostExchange; @RestController @@ -33,19 +38,22 @@ public class StiProtectionTestHelperController extends TestHelperController private final StiProtectionPopulator populator; private final TextTemplatePopulator textTemplatePopulator; private final OverdueProceduresNotifier overdueProceduresNotifier; + private final StiProtectionTestHelperService testHelperService; public StiProtectionTestHelperController( - StiProtectionTestHelperService testHelperService, + DefaultTestHelperService testHelperService, AuditLogTestHelperService auditLogTestHelperService, StiProtectionPopulator populator, TextTemplatePopulator textTemplatePopulator, EnvironmentConfig environmentConfig, - OverdueProceduresNotifier overdueProceduresNotifier) { + OverdueProceduresNotifier overdueProceduresNotifier, + StiProtectionTestHelperService testHelperService1) { super(testHelperService, environmentConfig); this.auditLogTestHelperService = auditLogTestHelperService; this.populator = populator; this.textTemplatePopulator = textTemplatePopulator; this.overdueProceduresNotifier = overdueProceduresNotifier; + this.testHelperService = testHelperService1; } @PostExchange("/population/procedures") @@ -71,6 +79,12 @@ public class StiProtectionTestHelperController extends TestHelperController return ResponseEntity.ok().build(); } + @GetExchange("/procedure/{procedureId}/citizen-user-id") + @Transactional(readOnly = true) + public UUID getCitizenUserId(@PathVariable("procedureId") UUID procedureId) { + return testHelperService.getCitizenUserId(procedureId); + } + @Override public void runAuditLogArchivingJob() { auditLogTestHelperService.runAuditLogArchivingJob(); diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/testhelper/StiProtectionTestHelperResetAction.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/testhelper/StiProtectionTestHelperResetAction.java new file mode 100644 index 0000000000000000000000000000000000000000..73732c08773d3efe470e3f092bca38c93411841c --- /dev/null +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/testhelper/StiProtectionTestHelperResetAction.java @@ -0,0 +1,29 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.stiprotection.testhelper; + +import de.eshg.lib.appointmentblock.persistence.CreateAppointmentTypeTask; +import de.eshg.testhelper.ConditionalOnTestHelperEnabled; +import de.eshg.testhelper.TestHelperServiceResetAction; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@ConditionalOnTestHelperEnabled +@Component +@Order(50) +public class StiProtectionTestHelperResetAction implements TestHelperServiceResetAction { + + private final CreateAppointmentTypeTask createAppointmentTypeTask; + + public StiProtectionTestHelperResetAction(CreateAppointmentTypeTask createAppointmentTypeTask) { + this.createAppointmentTypeTask = createAppointmentTypeTask; + } + + @Override + public void reset() { + createAppointmentTypeTask.createAppointmentTypes(); + } +} diff --git a/backend/sti-protection/src/main/java/de/eshg/stiprotection/testhelper/StiProtectionTestHelperService.java b/backend/sti-protection/src/main/java/de/eshg/stiprotection/testhelper/StiProtectionTestHelperService.java index 5099c5c44f49d37e208f3916024a7b5fbd01842e..fd5f0d49c47de74c48c6ea5880c142f21fef9185 100644 --- a/backend/sti-protection/src/main/java/de/eshg/stiprotection/testhelper/StiProtectionTestHelperService.java +++ b/backend/sti-protection/src/main/java/de/eshg/stiprotection/testhelper/StiProtectionTestHelperService.java @@ -5,21 +5,21 @@ package de.eshg.stiprotection.testhelper; -import de.eshg.lib.appointmentblock.persistence.CreateAppointmentTypeTask; +import de.eshg.stiprotection.StiProtectionProcedureFinder; import de.eshg.testhelper.*; import de.eshg.testhelper.environment.EnvironmentConfig; import de.eshg.testhelper.interception.TestRequestInterceptor; import de.eshg.testhelper.population.BasePopulator; import java.time.Clock; -import java.time.Instant; import java.util.List; +import java.util.UUID; import org.springframework.stereotype.Service; @ConditionalOnTestHelperEnabled @Service public class StiProtectionTestHelperService extends DefaultTestHelperService { - private final CreateAppointmentTypeTask createAppointmentTypeTask; + private final StiProtectionProcedureFinder procedureFinder; public StiProtectionTestHelperService( DatabaseResetHelper databaseResetHelper, @@ -27,22 +27,21 @@ public class StiProtectionTestHelperService extends DefaultTestHelperService { Clock clock, List<BasePopulator<?>> populators, List<ResettableProperties> resettableProperties, - CreateAppointmentTypeTask createAppointmentTypeTask, - EnvironmentConfig environmentConfig) { + List<TestHelperServiceResetAction> resetActions, + EnvironmentConfig environmentConfig, + StiProtectionProcedureFinder procedureFinder) { super( databaseResetHelper, testRequestInterceptor, clock, populators, resettableProperties, + resetActions, environmentConfig); - this.createAppointmentTypeTask = createAppointmentTypeTask; + this.procedureFinder = procedureFinder; } - @Override - public Instant reset() throws Exception { - Instant newInstant = super.reset(); - createAppointmentTypeTask.createAppointmentTypes(); - return newInstant; + public UUID getCitizenUserId(UUID procedureId) { + return procedureFinder.findByExternalId(procedureId).getAnonymousUserId(); } } diff --git a/backend/sti-protection/src/main/resources/application.properties b/backend/sti-protection/src/main/resources/application.properties index a4046083b2bf9541f0ee411395a7bbda57fa2477..d3c23e8078598ac571ad547d799ff5fcbeddf8b0 100644 --- a/backend/sti-protection/src/main/resources/application.properties +++ b/backend/sti-protection/src/main/resources/application.properties @@ -25,7 +25,51 @@ eshg.population.default-number-of-entities-to-populate.appointment-block-group=0 eshg.sti-protection.overdue-procedures.cron=0 0 1 * * * eshg.sti-protection.overdue-procedures.overdue-days=180 +eshg.sti-protection.unconfirmed-appointments.cron:@hourly +# https://docs.spring.io/spring-boot/reference/features/external-config.html#features.external-config.typesafe-configuration-properties.conversion.durations +eshg.sti-protection.unconfirmed-appointments.expire-after:1h + de.eshg.sti-protection.medical-history.consultation-de-location=classpath:templates/documents/medical_history_consultation_printable_de.pdf de.eshg.sti-protection.medical-history.consultation-en-location=classpath:templates/documents/medical_history_consultation_printable_en.pdf de.eshg.sti-protection.medical-history.sexwork-de-location=classpath:templates/documents/medical_history_sexwork_printable_de.pdf de.eshg.sti-protection.medical-history.sexwork-en-location=classpath:templates/documents/medical_history_sexwork_printable_en.pdf + +de.eshg.sti-protection.department-info.hiv_sti_consultation.name=HIV/STI - Beratung +de.eshg.sti-protection.department-info.hiv_sti_consultation.abbreviation=HIV-STI-Beratung +de.eshg.sti-protection.department-info.hiv_sti_consultation.street=Breite Gasse +de.eshg.sti-protection.department-info.hiv_sti_consultation.houseNumber=28 +de.eshg.sti-protection.department-info.hiv_sti_consultation.postalCode=60313 +de.eshg.sti-protection.department-info.hiv_sti_consultation.city=Frankfurt am Main +de.eshg.sti-protection.department-info.hiv_sti_consultation.country=DE +de.eshg.sti-protection.department-info.hiv_sti_consultation.phoneNumber=+49 69 212 43270 +de.eshg.sti-protection.department-info.hiv_sti_consultation.homepage=https://frankfurt.de/service-und-rathaus/verwaltung/aemter-und-institutionen/gesundheitsamt +de.eshg.sti-protection.department-info.hiv_sti_consultation.email=sexuelle.gesundheit@stadt-frankfurt.de +# de.eshg.sti-protection.department-info.hiv_sti_consultation.latitude= +# de.eshg.sti-protection.department-info.hiv_sti_consultation.longitude= + +# can be set individually to overwrite base department infos +de.eshg.sti-protection.department-info.sex_work.name=Gesundheitsamt Frankfurt am Main +de.eshg.sti-protection.department-info.sex_work.abbreviation=Sexarbeit +de.eshg.sti-protection.department-info.sex_work.street=Breite Gasse +de.eshg.sti-protection.department-info.sex_work.houseNumber=28 +de.eshg.sti-protection.department-info.sex_work.postalCode=60313 +de.eshg.sti-protection.department-info.sex_work.city=Frankfurt am Main +de.eshg.sti-protection.department-info.sex_work.country=DE +de.eshg.sti-protection.department-info.sex_work.phoneNumber=+49 69 212 43270 +de.eshg.sti-protection.department-info.sex_work.homepage=https://frankfurt.de/service-und-rathaus/verwaltung/aemter-und-institutionen/gesundheitsamt +de.eshg.sti-protection.department-info.sex_work.email=sexuelle.gesundheit@stadt-frankfurt.de +# de.eshg.sti-protection.department-info.sex_work.latitude= +# de.eshg.sti-protection.department-info.sex_work.longitude= + + +de.eshg.sti-protection.opening-hours.sex_work.de[0]=Di, Mi +de.eshg.sti-protection.opening-hours.sex_work.de[1]=09:00 - 11:00 Uhr\nOffene Sprechstunde nur für Sexarbeiterinnen und Sexarbeiter + +de.eshg.sti-protection.opening-hours.sex_work.en[0]=Tu, We +de.eshg.sti-protection.opening-hours.sex_work.en[1]=09:00 - 11:00 am\nOpen consultation hours only for sex workers + +de.eshg.sti-protection.opening-hours.hiv_sti_consultation.de[0]=Di, Mi 09:00 - 11:00 Uhr +de.eshg.sti-protection.opening-hours.hiv_sti_consultation.de[1]=Offene Sprechstunde für alle + +de.eshg.sti-protection.opening-hours.hiv_sti_consultation.en[0]=Tu, We 09:00 - 11:00 am +de.eshg.sti-protection.opening-hours.hiv_sti_consultation.en[1]=Open consultation hours for everyone diff --git a/backend/sti-protection/src/main/resources/migrations/0049_refactor_anonymous_user_id.xml b/backend/sti-protection/src/main/resources/migrations/0049_refactor_anonymous_user_id.xml new file mode 100644 index 0000000000000000000000000000000000000000..5ed3955699138ead7509a1eb3834aa91ebd52352 --- /dev/null +++ b/backend/sti-protection/src/main/resources/migrations/0049_refactor_anonymous_user_id.xml @@ -0,0 +1,16 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 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="1739183888947-1"> + <addColumn tableName="sti_protection_procedure"> + <column name="anonymous_user_id" type="UUID"/> + </addColumn> + </changeSet> + <changeSet author="GA-Lotse" id="1739183888947-2"> + <dropColumn columnName="anonymous_user_id" tableName="person"/> + </changeSet> +</databaseChangeLog> diff --git a/backend/sti-protection/src/main/resources/migrations/0050_add_procedure_expiration.xml b/backend/sti-protection/src/main/resources/migrations/0050_add_procedure_expiration.xml new file mode 100644 index 0000000000000000000000000000000000000000..69748c593b73f2590be04cb3e78c18c7086a6ed8 --- /dev/null +++ b/backend/sti-protection/src/main/resources/migrations/0050_add_procedure_expiration.xml @@ -0,0 +1,31 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 cronn GmbH + SPDX-License-Identifier: AGPL-3.0-only +--> + +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> + <changeSet author="GA-Lotse" id="1739285380433-1"> + <createTable tableName="procedure_expiration"> + <column autoIncrement="true" name="id" type="BIGINT"> + <constraints nullable="false" primaryKey="true" + primaryKeyName="pk_procedure_expiration"/> + </column> + <column name="version" type="BIGINT"> + <constraints nullable="false"/> + </column> + <column name="created_at" type="TIMESTAMP WITH TIME ZONE"> + <constraints nullable="false"/> + </column> + <column name="procedure_external_id" type="UUID"/> + </createTable> + </changeSet> + <changeSet author="GA-Lotse" id="1739285380433-2"> + <createIndex indexName="idx_procedure_expiration_created_at" + tableName="procedure_expiration"> + <column name="created_at"/> + </createIndex> + </changeSet> +</databaseChangeLog> diff --git a/backend/sti-protection/src/main/resources/migrations/0051_differentiate_between_previous_person_and_facility_file_state.xml b/backend/sti-protection/src/main/resources/migrations/0051_differentiate_between_previous_person_and_facility_file_state.xml new file mode 100644 index 0000000000000000000000000000000000000000..8729be621eb1c8a265e94bdcd231a834de5bd271 --- /dev/null +++ b/backend/sti-protection/src/main/resources/migrations/0051_differentiate_between_previous_person_and_facility_file_state.xml @@ -0,0 +1,21 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 cronn GmbH + SPDX-License-Identifier: AGPL-3.0-only +--> + +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> + <changeSet author="GA-Lotse" id="1738231823333-1"> + <renameColumn tableName="system_progress_entry" + oldColumnName="previous_file_state_id" + newColumnName="previous_person_file_state_id"/> + <addColumn tableName="system_progress_entry"> + <column name="previous_facility_file_state_id" type="UUID"/> + </addColumn> + <addUniqueConstraint columnNames="previous_facility_file_state_id" + constraintName="system_progress_entry_previous_facility_file_state_id_key" + tableName="system_progress_entry"/> + </changeSet> +</databaseChangeLog> diff --git a/backend/sti-protection/src/main/resources/migrations/0052_oms_appointment_type_extensions.xml b/backend/sti-protection/src/main/resources/migrations/0052_oms_appointment_type_extensions.xml new file mode 100644 index 0000000000000000000000000000000000000000..fe8353c1c4ab76ef4cbb9161cc1d1db64e3a813e --- /dev/null +++ b/backend/sti-protection/src/main/resources/migrations/0052_oms_appointment_type_extensions.xml @@ -0,0 +1,11 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 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="1739261126376-1"> + <ext:modifyPostgresEnumType name="appointmenttype" newValues="CAN_CHILD, CONSULTATION, ENTRY_LEVEL, HIV_STI_CONSULTATION, OFFICIAL_MEDICAL_SERVICE_LONG, OFFICIAL_MEDICAL_SERVICE_SHORT, PROOF_SUBMISSION, REGULAR_EXAMINATION, RESULTS_REVIEW, SEX_WORK, SPECIAL_NEEDS, VACCINATION"/> + </changeSet> +</databaseChangeLog> diff --git a/backend/sti-protection/src/main/resources/migrations/0053_idx_procedure_expiration_by_external_id.xml b/backend/sti-protection/src/main/resources/migrations/0053_idx_procedure_expiration_by_external_id.xml new file mode 100644 index 0000000000000000000000000000000000000000..1ffef5a5f52df78aa4f36aa8ab703f2ede00154c --- /dev/null +++ b/backend/sti-protection/src/main/resources/migrations/0053_idx_procedure_expiration_by_external_id.xml @@ -0,0 +1,16 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 cronn GmbH + SPDX-License-Identifier: AGPL-3.0-only +--> + +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> + <changeSet author="GA-Lotse" id="1739537776254-1"> + <createIndex indexName="idx_procedure_expiration_procedure_external_id" + tableName="procedure_expiration"> + <column name="procedure_external_id"/> + </createIndex> + </changeSet> +</databaseChangeLog> diff --git a/backend/sti-protection/src/main/resources/migrations/0054_rename_rapid_test_syphilis_test_data_column.xml b/backend/sti-protection/src/main/resources/migrations/0054_rename_rapid_test_syphilis_test_data_column.xml new file mode 100644 index 0000000000000000000000000000000000000000..efefe14386047dd23617447a9138c2f834f542fe --- /dev/null +++ b/backend/sti-protection/src/main/resources/migrations/0054_rename_rapid_test_syphilis_test_data_column.xml @@ -0,0 +1,24 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 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="1739465297745-1"> + <addColumn tableName="rapid_test_examination"> + <column name="syphilis_number" type="TEXT"/> + </addColumn> + </changeSet> + <changeSet author="GA-Lotse" id="1739465297745-2"> + <addColumn tableName="rapid_test_examination"> + <column name="syphilis_result" type="BOOLEAN"/> + </addColumn> + </changeSet> + <changeSet author="GA-Lotse" id="1739465297745-3"> + <dropColumn columnName="syphillis_number" tableName="rapid_test_examination"/> + </changeSet> + <changeSet author="GA-Lotse" id="1739465297745-4"> + <dropColumn columnName="syphillis_result" tableName="rapid_test_examination"/> + </changeSet> +</databaseChangeLog> diff --git a/backend/sti-protection/src/main/resources/migrations/changelog.xml b/backend/sti-protection/src/main/resources/migrations/changelog.xml index aee80fc88ea55246f916f22475ae0a0eec93c93e..4be33e14d19e5ac9bb7ee32cc4cbb0c5bfc5d8f8 100644 --- a/backend/sti-protection/src/main/resources/migrations/changelog.xml +++ b/backend/sti-protection/src/main/resources/migrations/changelog.xml @@ -56,5 +56,11 @@ <include file="migrations/0046_add_appointment_start.xml"/> <include file="migrations/0047_add_auditlog_entry.xml"/> <include file="migrations/0048_convert_duration_columns_to_interval.xml"/> + <include file="migrations/0049_refactor_anonymous_user_id.xml"/> + <include file="migrations/0050_add_procedure_expiration.xml"/> + <include file="migrations/0051_differentiate_between_previous_person_and_facility_file_state.xml"/> + <include file="migrations/0052_oms_appointment_type_extensions.xml"/> + <include file="migrations/0053_idx_procedure_expiration_by_external_id.xml"/> + <include file="migrations/0054_rename_rapid_test_syphilis_test_data_column.xml"/> </databaseChangeLog> diff --git a/backend/sti-protection/src/main/resources/templates/documents/medical_history_consultation_printable_de.pdf b/backend/sti-protection/src/main/resources/templates/documents/medical_history_consultation_printable_de.pdf index 2a2b8a26956ddf3b3eb6bbb65e2759a0a2c4b5c0..cce3b2b0d040dfcd274313d69136ec82f3529133 100644 Binary files a/backend/sti-protection/src/main/resources/templates/documents/medical_history_consultation_printable_de.pdf and b/backend/sti-protection/src/main/resources/templates/documents/medical_history_consultation_printable_de.pdf differ diff --git a/backend/sti-protection/src/main/resources/templates/documents/medical_history_sexwork_printable_de.pdf b/backend/sti-protection/src/main/resources/templates/documents/medical_history_sexwork_printable_de.pdf index 277b2609cf0a29bd7c41890eba1df95e77b5090f..33639f70a415b454c11dc8c7d99b345e57334b7b 100644 Binary files a/backend/sti-protection/src/main/resources/templates/documents/medical_history_sexwork_printable_de.pdf and b/backend/sti-protection/src/main/resources/templates/documents/medical_history_sexwork_printable_de.pdf differ diff --git a/backend/synapse/dev-tools/add-email-to-3pid-password.http b/backend/synapse/dev-tools/add-email-to-3pid-password.http new file mode 100644 index 0000000000000000000000000000000000000000..7d8e1ebfbe07b0bf372ec97765f0a63ad3e2c2cd --- /dev/null +++ b/backend/synapse/dev-tools/add-email-to-3pid-password.http @@ -0,0 +1,71 @@ +### Login +POST http://localhost:8008/_matrix/client/r0/login +Accept: application/json +Content-Type: application/json + +{ + "type":"m.login.password", + "user": "testuser1", + "password": "password" +} + +### get 3pid +GET http://localhost:8008/_matrix/client/v3/account/3pid +Accept: application/json +Content-Type: application/json +Authorization: Bearer [SYNAPSE_ACCESS_TOKEN] + + +#### send 3pid +POST http://localhost:8008/_matrix/client/v3/account/3pid/email/requestToken +Accept: application/json +Content-Type: application/json +Authorization: Bearer [SYNAPSE_ACCESS_TOKEN] + +{ + "client_secret": "random_uuid_string_asdfgasdf", + "email": "user@example.com", + "send_attempt": 1, + "next_link": null +} + +### Now, go to maildev http://localhost:1080/ and click confirmation link in new email message + + +### bind 3pid - first call to get the session +POST http://localhost:8008/_matrix/client/v3/account/3pid/add +Accept: application/json +Content-Type: application/json +Authorization: Bearer [SYNAPSE_ACCESS_TOKEN] + +{ + "sid": "[SID_FROM_REQUEST_TOKEN_REQUEST]", + "client_secret": "random_uuid_string_asdfgasdf" +} + + +### bind 3pid - second call with session AND mandatory auth credentials (username + password) +POST http://localhost:8008/_matrix/client/v3/account/3pid/add +Accept: application/json +Content-Type: application/json +Authorization: Bearer [SYNAPSE_ACCESS_TOKEN] + +{ + "sid": "WxKSyBqxERgADHKs", + "client_secret": "random_uuid_string_asdfgasdf", + "auth": { + "type": "m.login.password", + "user": "testuser1", + "password": "password", + "session": "[SESSION_FROM_PREVIOUS_BIND_3PID_REQUEST]" + } +} + + +### check if email was added to 3pids +GET http://localhost:8008/_matrix/client/v3/account/3pid +Accept: application/json +Content-Type: application/json +Authorization: Bearer [SYNAPSE_ACCESS_TOKEN] + + diff --git a/backend/synapse/dev-tools/admin-add-idp-to-user.http b/backend/synapse/dev-tools/admin-add-idp-to-user.http new file mode 100644 index 0000000000000000000000000000000000000000..3ab3358de54c96460e9dd4c736dbd8cfdb86739d --- /dev/null +++ b/backend/synapse/dev-tools/admin-add-idp-to-user.http @@ -0,0 +1,33 @@ +### Login as admin +POST http://localhost:8008/_matrix/client/r0/login +Accept: application/json +Content-Type: application/json + +{ + "type":"m.login.password", + "user": "admin", + "password": "admin" +} + + +### Get user data +GET http://localhost:8008/_synapse/admin/v2/users/@61dafa5b-2439-4165-9bb4-7ee2966577e4:synapse.local.dev +Accept: application/json +Content-Type: application/json +Authorization: Bearer syt_YWRtaW4_ZWFfzXGTQDvJsYKtyLHB_3yvtFk + + +### Add keycloak external_id to user +PUT http://localhost:8008/_synapse/admin/v2/users/@61dafa5b-2439-4165-9bb4-7ee2966577e4:synapse.local.dev +Accept: application/json +Content-Type: application/json +Authorization: Bearer syt_YWRtaW4_ZWFfzXGTQDvJsYKtyLHB_3yvtFk + +{ + "external_ids": [ + { + "auth_provider": "oidc-keycloak", + "external_id": "61dafa5b-2439-4165-9bb4-7ee2966577e4" + } + ] +} diff --git a/backend/synapse/dev-tools/admin-api.http b/backend/synapse/dev-tools/admin-api.http new file mode 100644 index 0000000000000000000000000000000000000000..406a2c963b1f8efec7cc307b47a57e3d90a9b80d --- /dev/null +++ b/backend/synapse/dev-tools/admin-api.http @@ -0,0 +1,19 @@ +### Login as admin + +POST http://localhost:8008/_matrix/client/r0/login +Accept: application/json +Content-Type: application/json + +{ + "type":"m.login.password", + "user": "admin", + "password": "admin" +} + + +### Allow replacing master cross-signing key without User-Interactive Auth (for next 10 minutes) + +POST http://localhost:8008/_synapse/admin/v1/users/@testuser1:synapse.local.dev/_allow_cross_signing_replacement_without_uia +Accept: application/json +Content-Type: application/json +Authorization: Bearer [SYNAPSE_ACCESS_TOKEN] diff --git a/backend/synapse/dev-tools/deactivate-account-with-password.http b/backend/synapse/dev-tools/deactivate-account-with-password.http new file mode 100644 index 0000000000000000000000000000000000000000..f1490ced6b18705c40b02969cc51afef465e2c99 --- /dev/null +++ b/backend/synapse/dev-tools/deactivate-account-with-password.http @@ -0,0 +1,37 @@ +### Login +POST http://localhost:8008/_matrix/client/r0/login +Accept: application/json +Content-Type: application/json + +{ + "type":"m.login.password", + "user": "testuser1", + "password": "password" +} + + +### Deactivate account +POST http://localhost:8008/_matrix/client/v3/account/deactivate +Accept: application/json +Content-Type: application/json +Authorization: Bearer [ACCESS_TOKEN] + +{ + "auth": { + "type": "m.login.password", + "user": "testuser1", + "password": "password" + } +} + + +### Try to login to check if user does not exist anymore +POST http://localhost:8008/_matrix/client/r0/login +Accept: application/json +Content-Type: application/json + +{ + "type":"m.login.password", + "user": "testuser1", + "password": "password" +} diff --git a/backend/synapse/dev-tools/drafts/deactivate-account-with-email.http b/backend/synapse/dev-tools/drafts/deactivate-account-with-email.http new file mode 100644 index 0000000000000000000000000000000000000000..2d64771f5686314bf3135c4839ec0e83cd5df5ff --- /dev/null +++ b/backend/synapse/dev-tools/drafts/deactivate-account-with-email.http @@ -0,0 +1,102 @@ +### STEP1: add email to 3pid's + +### Login +POST http://localhost:8008/_matrix/client/r0/login +Accept: application/json +Content-Type: application/json + +{ + "type":"m.login.password", + "user": "testuser2", + "password": "password" +} + +### get 3pids, see empty list +GET http://localhost:8008/_matrix/client/v3/account/3pid +Accept: application/json +Content-Type: application/json +Authorization: Bearer [ACCESS_TOKEN] + + +#### send request to add email to 3pid +POST http://localhost:8008/_matrix/client/v3/account/3pid/email/requestToken +Accept: application/json +Content-Type: application/json +Authorization: Bearer [ACCESS_TOKEN] + +{ + "client_secret": "random_string_asdfgasdf", + "email": "user@example.com", + "send_attempt": 1, + "next_link": null +} + +### Now, go to maildev http://localhost:1080/ and click confirmation link in new email message + + +### bind email to 3pid's - first call to get the session +POST http://localhost:8008/_matrix/client/v3/account/3pid/add +Accept: application/json +Content-Type: application/json +Authorization: Bearer [ACCESS_TOKEN] + +{ + "sid": "CGcCfkMNcLjSMIQE", + "client_secret": "random_string_asdfgasdf" +} + + +### bind 3pid - second call with session AND mandatory auth credentials (username + password) +POST http://localhost:8008/_matrix/client/v3/account/3pid/add +Accept: application/json +Content-Type: application/json +Authorization: Bearer [ACCESS_TOKEN] + +{ + "sid": "CGcCfkMNcLjSMIQE", + "client_secret": "random_string_asdfgasdf", + "auth": { + "type": "m.login.password", + "user": "testuser2", + "password": "password", + "session": "tuipxIsEqdifpPGQqxmGrnCY" + } +} + + +### check if email was added to 3pids +GET http://localhost:8008/_matrix/client/v3/account/3pid +Accept: application/json +Content-Type: application/json +Authorization: Bearer [ACCESS_TOKEN] + + +### Step 2: perform account deactivation with email + +##### request new 3pid token +POST http://localhost:8008/_matrix/client/v3/account/3pid/email/requestToken +Accept: application/json +Content-Type: application/json +Authorization: Bearer [ACCESS_TOKEN] + +{ + "client_secret": "random_string_asdfgasdf", + "email": "secondemail@example.com", + "send_attempt": 1, + "next_link": null +} + + +### Deactivate account - NOT WORKING! +POST http://localhost:8008/_matrix/client/v3/account/deactivate +Accept: application/json +Content-Type: application/json +Authorization: Bearer [ACCESS_TOKEN] + +{ + "threepid_creds": { + "sid": "DNgdmxBKwglDjWBX", + "client_secret": "random_string_asdfgasdf" + } +} + diff --git a/backend/synapse/dev-tools/drafts/deactivate-account-with-jwt.http b/backend/synapse/dev-tools/drafts/deactivate-account-with-jwt.http new file mode 100644 index 0000000000000000000000000000000000000000..9aedb778ec660159ec55118a3446a445cab3668b --- /dev/null +++ b/backend/synapse/dev-tools/drafts/deactivate-account-with-jwt.http @@ -0,0 +1,27 @@ +### Get login options +GET http://localhost:8008/_matrix/client/r0/login +Accept: application/json +Content-Type: application/json + + +### Obtain synapseAccessToken from MatrixLoginClient.java (temporary add println synapseAccessToken to login method) + + +### Test token with whoami user data (requires Authorization) +GET http://localhost:8008/_matrix/client/v3/account/whoami +Accept: application/json +Content-Type: application/json +Authorization: Bearer [ACCESS_TOKEN] + + +### Deactivate account - NOT WORKING +POST http://localhost:8008/_matrix/client/v3/account/deactivate +Accept: application/json +Content-Type: application/json +Authorization: Bearer [ACCESS_TOKEN] + +{ + "auth": { + "session": "xVkTWYHtZDboNjdfiZFyYxAL" + } +} diff --git a/backend/synapse/dev-tools/drafts/upload-cross-signing-keys.http b/backend/synapse/dev-tools/drafts/upload-cross-signing-keys.http new file mode 100644 index 0000000000000000000000000000000000000000..e66eb6436020686bf54927fcf153a36be0820c84 --- /dev/null +++ b/backend/synapse/dev-tools/drafts/upload-cross-signing-keys.http @@ -0,0 +1,51 @@ + +### Login with username and password to obtain AccessToken +POST http://localhost:8008/_matrix/client/r0/login +Accept: application/json +Content-Type: application/json + +{ + "type":"m.login.password", + "user": "testuser1", + "password": "password" +} + +#### Get whoami user data (requires Authorization) +GET http://localhost:8008/_matrix/client/v3/account/whoami +Accept: application/json +Content-Type: application/json +Authorization: Bearer syt_dGVzdHVzZXIx_cCUHJQkDRgSxZMalphQc_4Ti1ZP + + +### Allow replacing master cross-signing key without User-Interactive Auth (for next 10 minutes) + +POST http://localhost:8008/_synapse/admin/v1/users/@testuser1:synapse.local.dev/_allow_cross_signing_replacement_without_uia +Accept: application/json +Content-Type: application/json +Authorization: Bearer syt_YWRtaW4_OsXwvIvsSxxsYWbwmelq_27B2FX + + +### [not working] Upload cross signing keys +POST http://localhost:8008/_matrix/client/v3/keys/device_signing/upload +Accept: application/json +Content-Type: application/json +Authorization: Bearer syt_dGVzdHVzZXIx_cldbKIvdYMYZnoaaKvlk_0DhVSC + +{ + "auth": { + "session": "wkRwhQvCrzzGpEbJqLMqrsDD" + }, + "master_key": { + "keys": { + "ed25519:base64+master+public+key": "base64+master+public+key" + }, + "usage": [ + "master" + ], + "user_id": "@testuser1:synapse.local.dev" + } +} + + + + diff --git a/backend/synapse/dev-tools/synapse-password-login.http b/backend/synapse/dev-tools/synapse-password-login.http new file mode 100644 index 0000000000000000000000000000000000000000..581ed238c1c92c3be35ae228a51b3aeaafd32350 --- /dev/null +++ b/backend/synapse/dev-tools/synapse-password-login.http @@ -0,0 +1,17 @@ +### Login with username and password to obtain AccessToken +POST http://localhost:8008/_matrix/client/r0/login +Accept: application/json +Content-Type: application/json + +{ + "type":"m.login.password", + "user": "testuser1", + "password": "password" +} + +### Get whoami user data (requires Authorization) +GET http://localhost:8008/_matrix/client/v3/account/whoami +Accept: application/json +Content-Type: application/json +Authorization: Bearer <SYNAPSE_ACCESS_TOKEN> + diff --git a/backend/test-helper-commons/src/main/java/de/eshg/testhelper/DatabaseResetAction.java b/backend/test-helper-commons/src/main/java/de/eshg/testhelper/DatabaseResetAction.java new file mode 100644 index 0000000000000000000000000000000000000000..c497efe59d5327dfb857b706902e067134107fca --- /dev/null +++ b/backend/test-helper-commons/src/main/java/de/eshg/testhelper/DatabaseResetAction.java @@ -0,0 +1,41 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.testhelper; + +import java.sql.SQLException; +import org.apache.commons.lang3.exception.UncheckedException; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@ConditionalOnBean(DatabaseResetHelper.class) +@ConditionalOnTestHelperEnabled +@ConditionalOnMissingBean(DatabaseResetAction.class) +@Component +@Order(10) +public class DatabaseResetAction implements TestHelperServiceResetAction { + + private final DatabaseResetHelper databaseResetHelper; + + public DatabaseResetAction(DatabaseResetHelper databaseResetHelper) { + this.databaseResetHelper = databaseResetHelper; + } + + @Override + public void reset() { + try { + databaseResetHelper.truncateAllTables(getTablesToExclude()); + } catch (SQLException e) { + throw new UncheckedException(e); + } + databaseResetHelper.resetAllSequences(); + } + + protected String[] getTablesToExclude() { + return new String[] {}; + } +} diff --git a/backend/test-helper-commons/src/main/java/de/eshg/testhelper/DefaultTestHelperService.java b/backend/test-helper-commons/src/main/java/de/eshg/testhelper/DefaultTestHelperService.java index 7fe639a351222685eb6efbb2e5867c15cb85424c..957a3cab3a1a2708a152721ffc413be3a66ef8bf 100644 --- a/backend/test-helper-commons/src/main/java/de/eshg/testhelper/DefaultTestHelperService.java +++ b/backend/test-helper-commons/src/main/java/de/eshg/testhelper/DefaultTestHelperService.java @@ -13,13 +13,14 @@ import de.eshg.testhelper.interception.TestHelperInterceptionRequestFilter; import de.eshg.testhelper.interception.TestRequestInterceptor; import de.eshg.testhelper.population.BasePopulator; import de.eshg.testhelper.population.ListWithTotalNumber; -import java.sql.SQLException; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.Period; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Collectors; @@ -28,6 +29,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Service; @Service @@ -43,6 +45,7 @@ public class DefaultTestHelperService implements TestHelperWithDatabaseService { protected final List<BasePopulator<?>> populators; protected final List<ResettableProperties> resettableProperties; + private final List<TestHelperServiceResetAction> resetActions; private final Map<ResettableProperties, String> initialResettablePropertiesSnapshots; protected final EnvironmentConfig environmentConfig; @@ -53,6 +56,7 @@ public class DefaultTestHelperService implements TestHelperWithDatabaseService { Clock clock, List<BasePopulator<?>> populators, List<ResettableProperties> resettableProperties, + List<TestHelperServiceResetAction> resetActions, EnvironmentConfig environmentConfig) { environmentConfig.assertIsNotProduction(); log.warn("Creating {}", getClass().getSimpleName()); @@ -62,6 +66,7 @@ public class DefaultTestHelperService implements TestHelperWithDatabaseService { this.clock = clock; this.populators = populators; this.resettableProperties = resettableProperties; + this.resetActions = assertOrdered(resetActions); this.initialResettablePropertiesSnapshots = resettableProperties.stream() .collect(Collectors.toMap(Function.identity(), SnapshotUtil::createSnapshot)); @@ -70,18 +75,25 @@ public class DefaultTestHelperService implements TestHelperWithDatabaseService { @Override public Instant reset() throws Exception { environmentConfig.assertIsNotProduction(); - if (databaseResetHelper != null) { - resetDatabase(); - } - resetInterceptions(); resetResettableProperties(); - withTestClock(TestHelperClock::reset); + resetActions.forEach(TestHelperServiceResetAction::reset); return Instant.now(clock); } - private void resetDatabase() throws SQLException { - databaseResetHelper.truncateAllTables(getTablesToExclude()); - databaseResetHelper.resetAllSequences(); + public String describeResetHelperSetup() { + if (resetActions.isEmpty()) { + return "TestHelperServiceResetAction beans: none"; + } else { + return "TestHelperServiceResetAction beans:\n" + + resetActions.stream() + .map(TestHelperServiceResetAction::getClass) + .map( + clazz -> + "%s (@Order(%d))" + .formatted(clazz.getName(), clazz.getAnnotation(Order.class).value())) + .collect(Collectors.joining("\n")) + .indent(4); + } } @Override @@ -121,10 +133,6 @@ public class DefaultTestHelperService implements TestHelperWithDatabaseService { SnapshotUtil.restoreSnapshot(resettablePropertiesSnapshot, resettableProperties); } - protected String[] getTablesToExclude() { - return new String[] {}; - } - @Override public void interceptNextRequest( InterceptionType type, TestHelperInterceptionRequestFilter filter) { @@ -180,4 +188,25 @@ public class DefaultTestHelperService implements TestHelperWithDatabaseService { .toList(); return new DefaultPopulationResponse(populations); } + + private List<TestHelperServiceResetAction> assertOrdered( + List<TestHelperServiceResetAction> actions) { + Set<Integer> orders = new HashSet<>(); + actions.stream() + .map(TestHelperServiceResetAction::getClass) + .forEach( + actionClazz -> { + Order orderAnnotation = actionClazz.getAnnotation(Order.class); + if (orderAnnotation == null) { + throw new IllegalArgumentException( + "Missing @Order for resetAction: %s".formatted(actionClazz.getName())); + } + if (!orders.add(orderAnnotation.value())) { + throw new IllegalArgumentException( + "Duplicate @Order value %d for resetAction: %s" + .formatted(orderAnnotation.value(), actionClazz.getName())); + } + }); + return actions; + } } 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 1bc2a85dd011217ddbed16d81961ccbd15f11a51..179c1540000ee021b943a2025983d29c8344904b 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 @@ -25,7 +25,9 @@ import org.springframework.context.annotation.*; DefaultTestHelperService.class, TestRequestInterceptor.class, PopulateWithAccessTokenHelper.class, - DatabaseResetHelper.class + DatabaseResetHelper.class, + DatabaseResetAction.class, + TestHelperClockResetAction.class, }) @ConditionalOnTestHelperEnabled public class TestHelperAutoConfiguration { diff --git a/backend/test-helper-commons/src/main/java/de/eshg/testhelper/TestHelperClockResetAction.java b/backend/test-helper-commons/src/main/java/de/eshg/testhelper/TestHelperClockResetAction.java new file mode 100644 index 0000000000000000000000000000000000000000..ec5a5c125e5e2af22d7b35fb92cf94a1af1c7f24 --- /dev/null +++ b/backend/test-helper-commons/src/main/java/de/eshg/testhelper/TestHelperClockResetAction.java @@ -0,0 +1,35 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.testhelper; + +import java.time.Clock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@ConditionalOnTestHelperEnabled +@Component +@Order(30) +public class TestHelperClockResetAction implements TestHelperServiceResetAction { + + private static final Logger log = LoggerFactory.getLogger(TestHelperClockResetAction.class); + + private final Clock clock; + + public TestHelperClockResetAction(Clock clock) { + this.clock = clock; + } + + @Override + public void reset() { + if (clock instanceof TestHelperClock testHelperClock) { + testHelperClock.reset(); + } else { + log.warn("Test clock is disabled"); + } + } +} diff --git a/backend/test-helper-commons/src/main/java/de/eshg/testhelper/TestHelperServiceResetAction.java b/backend/test-helper-commons/src/main/java/de/eshg/testhelper/TestHelperServiceResetAction.java new file mode 100644 index 0000000000000000000000000000000000000000..4bc4e91fd1eade5061dc446c1c7d2a99a1b4762f --- /dev/null +++ b/backend/test-helper-commons/src/main/java/de/eshg/testhelper/TestHelperServiceResetAction.java @@ -0,0 +1,10 @@ +/* + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.eshg.testhelper; + +public interface TestHelperServiceResetAction { + void reset(); +} diff --git a/backend/test-helper-commons/src/main/java/de/eshg/testhelper/interception/TestRequestInterceptor.java b/backend/test-helper-commons/src/main/java/de/eshg/testhelper/interception/TestRequestInterceptor.java index 6e339de54a75cfcf1019b5d16415933c7d501fa0..fabade76890e460fe95e522e3f166774ae6ecf46 100644 --- a/backend/test-helper-commons/src/main/java/de/eshg/testhelper/interception/TestRequestInterceptor.java +++ b/backend/test-helper-commons/src/main/java/de/eshg/testhelper/interception/TestRequestInterceptor.java @@ -10,6 +10,7 @@ import de.eshg.rest.service.error.ErrorCode; import de.eshg.rest.service.error.ErrorResponse; import de.eshg.testhelper.ConditionalOnTestHelperEnabled; import de.eshg.testhelper.TestHelperController; +import de.eshg.testhelper.TestHelperServiceResetAction; import de.eshg.testhelper.environment.EnvironmentConfig; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -31,6 +32,7 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicLong; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; @@ -39,7 +41,9 @@ import org.springframework.web.filter.OncePerRequestFilter; @Component @ConditionalOnTestHelperEnabled -public class TestRequestInterceptor extends OncePerRequestFilter { +@Order(20) +public class TestRequestInterceptor extends OncePerRequestFilter + implements TestHelperServiceResetAction { private static final Logger log = LoggerFactory.getLogger(TestRequestInterceptor.class); @@ -57,6 +61,7 @@ public class TestRequestInterceptor extends OncePerRequestFilter { log.warn("{} is enabled!", getClass().getSimpleName()); } + @Override public void reset() { if (!cyclicBarriers.isEmpty()) { log.warn("Clearing {} cyclic barriers", cyclicBarriers.size()); diff --git a/backend/travel-medicine/gradle.lockfile b/backend/travel-medicine/gradle.lockfile index 2537c3d60fb85d9bd8c4c4002797fcc0f8b74829..6cc1f3a24284dd0a6168fae01248dff65f19de40 100644 --- a/backend/travel-medicine/gradle.lockfile +++ b/backend/travel-medicine/gradle.lockfile @@ -90,6 +90,9 @@ net.bytebuddy:byte-buddy:1.15.11=annotationProcessor,productionRuntimeClasspath, net.datafaker:datafaker:2.4.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.13.0=testCompileClasspath,testRuntimeClasspath net.java.dev.stax-utils:stax-utils:20070216=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-core:6.2.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.2.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +net.javacrumbs.shedlock:shedlock-spring:6.2.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath net.logstash.logback:logstash-logback-encoder:8.0=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.5.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.minidev:json-smart:2.5.1=productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/backend/travel-medicine/openApi.json b/backend/travel-medicine/openApi.json index 3f3ad546a3865db888f6264f4ec623ea300f338e..6cd3f5da9747390313f2551c7748e908682187f4 100644 --- a/backend/travel-medicine/openApi.json +++ b/backend/travel-medicine/openApi.json @@ -5014,7 +5014,7 @@ }, "AppointmentType" : { "type" : "string", - "enum" : [ "CONSULTATION", "VACCINATION", "REGULAR_EXAMINATION", "CAN_CHILD", "ENTRY_LEVEL", "SPECIAL_NEEDS", "PROOF_SUBMISSION", "HIV_STI_CONSULTATION", "SEX_WORK", "RESULTS_REVIEW", "OFFICIAL_MEDICAL_SERVICE" ] + "enum" : [ "CONSULTATION", "VACCINATION", "REGULAR_EXAMINATION", "CAN_CHILD", "ENTRY_LEVEL", "SPECIAL_NEEDS", "PROOF_SUBMISSION", "HIV_STI_CONSULTATION", "SEX_WORK", "RESULTS_REVIEW", "OFFICIAL_MEDICAL_SERVICE_SHORT", "OFFICIAL_MEDICAL_SERVICE_LONG" ] }, "AppointmentTypeConfig" : { "required" : [ "appointmentTypeDto", "id", "standardDurationInMinutes" ], @@ -9073,7 +9073,11 @@ "type" : "integer", "format" : "int32" }, - "previousFileStateId" : { + "previousFacilityFileStateId" : { + "type" : "string", + "format" : "uuid" + }, + "previousPersonFileStateId" : { "type" : "string", "format" : "uuid" }, diff --git a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/citizenpublic/CitizenPublicController.java b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/citizenpublic/CitizenPublicController.java index 56381573a4fc77a62f2ff34a94afce14d2f3c467..9cf91000535e720bad5ca9996a80c9261be1f394 100644 --- a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/citizenpublic/CitizenPublicController.java +++ b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/citizenpublic/CitizenPublicController.java @@ -5,6 +5,9 @@ package de.eshg.travelmedicine.citizenpublic; +import static de.eshg.rest.service.PrivacyDocumentHelper.privacyNoticeAttachmentResponse; +import static de.eshg.rest.service.PrivacyDocumentHelper.privacyPolicyAttachmentResponse; + import de.eshg.base.department.GetDepartmentInfoResponse; import de.eshg.lib.appointmentblock.AppointmentBlockService; import de.eshg.lib.appointmentblock.AppointmentTypeService; @@ -30,8 +33,6 @@ import java.util.List; import java.util.UUID; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; -import org.springframework.http.ContentDisposition; -import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; @@ -149,30 +150,13 @@ public class CitizenPublicController { @Operation(summary = "Get the privacy-notice document.") @Transactional(readOnly = true) public ResponseEntity<Resource> getPrivacyNotice() { - return getPrivacyDocument(privacyNotice); + return privacyNoticeAttachmentResponse(privacyNotice); } @GetMapping(path = "/documents/privacy-policy") @Operation(summary = "Get the privacy-policy document.") @Transactional(readOnly = true) public ResponseEntity<Resource> getPrivacyPolicy() { - return getPrivacyDocument(privacyPolicy); - } - - private static ResponseEntity<Resource> getPrivacyDocument(Resource privacyDocument) { - return ResponseEntity.ok() - .header( - HttpHeaders.CONTENT_DISPOSITION, - fileAttachment(privacyDocument.getFilename()).toString()) - .header(HttpHeaders.CONTENT_TYPE, "application/pdf") - .body(privacyDocument); - } - - private static ContentDisposition fileAttachment(String filename) { - return file(filename, ContentDisposition.attachment()); - } - - private static ContentDisposition file(String filename, ContentDisposition.Builder builder) { - return builder.name("file").filename(filename).build(); + return privacyPolicyAttachmentResponse(privacyPolicy); } } 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 index 54f73a051a61fa9d35325372e58396bdf8864e48..2ff3081cca362ef9a1605f49a138b3c96f833d89 100644 --- 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 @@ -6,6 +6,7 @@ package de.eshg.travelmedicine.notification; import de.eshg.base.mail.MailApi; +import de.eshg.base.mail.MailType; import de.eshg.base.mail.SendEmailRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,7 +25,8 @@ public class MailClient { void sendMail(String to, String from, String subject, String text) { log.info("Sending E-Mail notification"); - SendEmailRequest sendEmailRequest = new SendEmailRequest(to, from, subject, text); + SendEmailRequest sendEmailRequest = + new SendEmailRequest(to, from, subject, text, MailType.PLAIN_TEXT); mailApi.sendEmail(sendEmailRequest); log.info("E-Mail notification send"); diff --git a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/testhelper/TravelMedicineTestHelperController.java b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/testhelper/TravelMedicineTestHelperController.java index 58d8c3418fe1a6bc243a8ac65bd463910b73ec7b..a3230f1aaf7ab46edf215a822bcc80e89e958d2b 100644 --- a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/testhelper/TravelMedicineTestHelperController.java +++ b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/testhelper/TravelMedicineTestHelperController.java @@ -8,6 +8,7 @@ package de.eshg.travelmedicine.testhelper; import de.eshg.auditlog.AuditLogClientTestHelperApi; import de.eshg.lib.auditlog.AuditLogTestHelperService; import de.eshg.testhelper.ConditionalOnTestHelperEnabled; +import de.eshg.testhelper.DefaultTestHelperService; import de.eshg.testhelper.TestHelperApi; import de.eshg.testhelper.TestHelperController; import de.eshg.testhelper.environment.EnvironmentConfig; @@ -39,7 +40,7 @@ public class TravelMedicineTestHelperController extends TestHelperController private final AuditLogTestHelperService auditLogTestHelperService; public TravelMedicineTestHelperController( - TravelMedicineTestHelperService travelMedicineTestHelperService, + DefaultTestHelperService travelMedicineTestHelperService, TravelMedicineFeatureToggle travelMedicineFeatureToggle, TestPopulateAdministrativeService testPopulateAdministrativeService, TestPopulateProcedureService testPopulateProcedureService, diff --git a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/testhelper/TravelMedicineTestHelperResetAction.java b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/testhelper/TravelMedicineTestHelperResetAction.java new file mode 100644 index 0000000000000000000000000000000000000000..6c85e2707f6314b467910aa316730b0348b7adf7 --- /dev/null +++ b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/testhelper/TravelMedicineTestHelperResetAction.java @@ -0,0 +1,35 @@ +/* + * Copyright 2025 SCOOP Software GmbH, cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package de.eshg.travelmedicine.testhelper; + +import de.eshg.lib.appointmentblock.persistence.CreateAppointmentTypeTask; +import de.eshg.testhelper.ConditionalOnTestHelperEnabled; +import de.eshg.testhelper.TestHelperServiceResetAction; +import de.eshg.travelmedicine.template.medicalhistorytemplate.persistence.CreateMedicalHistoryTemplateTask; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@ConditionalOnTestHelperEnabled +@Component +@Order(50) +public class TravelMedicineTestHelperResetAction implements TestHelperServiceResetAction { + + private final CreateAppointmentTypeTask createAppointmentTypeTask; + private final CreateMedicalHistoryTemplateTask createMedicalHistoryTemplateTask; + + public TravelMedicineTestHelperResetAction( + CreateAppointmentTypeTask createAppointmentTypeTask, + CreateMedicalHistoryTemplateTask createMedicalHistoryTemplateTask) { + this.createAppointmentTypeTask = createAppointmentTypeTask; + this.createMedicalHistoryTemplateTask = createMedicalHistoryTemplateTask; + } + + @Override + public void reset() { + createAppointmentTypeTask.createAppointmentTypes(); + createMedicalHistoryTemplateTask.createMedicalHistoryTemplate(); + } +} diff --git a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/testhelper/TravelMedicineTestHelperService.java b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/testhelper/TravelMedicineTestHelperService.java deleted file mode 100644 index 0c2f4dfbf5ccbbc6232d7aa19bb50d2cd7ba7e49..0000000000000000000000000000000000000000 --- a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/testhelper/TravelMedicineTestHelperService.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2025 SCOOP Software GmbH, cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package de.eshg.travelmedicine.testhelper; - -import de.eshg.lib.appointmentblock.persistence.CreateAppointmentTypeTask; -import de.eshg.testhelper.*; -import de.eshg.testhelper.environment.EnvironmentConfig; -import de.eshg.testhelper.interception.TestRequestInterceptor; -import de.eshg.testhelper.population.BasePopulator; -import de.eshg.travelmedicine.template.medicalhistorytemplate.persistence.CreateMedicalHistoryTemplateTask; -import java.time.Clock; -import java.time.Instant; -import java.util.List; -import org.springframework.stereotype.Service; - -@ConditionalOnTestHelperEnabled -@Service -public class TravelMedicineTestHelperService extends DefaultTestHelperService { - - private final CreateAppointmentTypeTask createAppointmentTypeTask; - private final CreateMedicalHistoryTemplateTask createMedicalHistoryTemplateTask; - - public TravelMedicineTestHelperService( - DatabaseResetHelper databaseResetHelper, - TestRequestInterceptor testRequestInterceptor, - Clock clock, - List<BasePopulator<?>> populators, - List<ResettableProperties> resettableProperties, - CreateAppointmentTypeTask createAppointmentTypeTask, - CreateMedicalHistoryTemplateTask createMedicalHistoryTemplateTask, - EnvironmentConfig environmentConfig) { - super( - databaseResetHelper, - testRequestInterceptor, - clock, - populators, - resettableProperties, - environmentConfig); - this.createAppointmentTypeTask = createAppointmentTypeTask; - this.createMedicalHistoryTemplateTask = createMedicalHistoryTemplateTask; - } - - @Override - public Instant reset() throws Exception { - Instant newInstant = super.reset(); - createAppointmentTypeTask.createAppointmentTypes(); - createMedicalHistoryTemplateTask.createMedicalHistoryTemplate(); - return newInstant; - } -} diff --git a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/vaccinationconsultation/VaccinationConsultationController.java b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/vaccinationconsultation/VaccinationConsultationController.java index 5a018325b1b235abc8cb28a054c5b9cf299f37c2..ae82fb833b4561eef8fc00df82cd6eb375882bb8 100644 --- a/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/vaccinationconsultation/VaccinationConsultationController.java +++ b/backend/travel-medicine/src/main/java/de/eshg/travelmedicine/vaccinationconsultation/VaccinationConsultationController.java @@ -7,6 +7,7 @@ package de.eshg.travelmedicine.vaccinationconsultation; import de.eshg.lib.auditlog.AuditLogger; import de.eshg.lib.procedure.model.ProcedureStatusDto; +import de.eshg.persistence.IntentionalWritingTransaction; import de.eshg.rest.service.security.CurrentUserHelper; import de.eshg.rest.service.security.config.BaseUrls; import de.eshg.travelmedicine.certificate.CertificateService; @@ -154,6 +155,7 @@ public class VaccinationConsultationController { @GetMapping(path = "/{procedureId}" + DETAILS_URL) @Operation(summary = "Get vaccination consultation details") @Transactional + @IntentionalWritingTransaction(reason = "Audit logging") public GetVaccinationConsultationDetailsResponse getVaccinationConsultationDetails( @PathVariable("procedureId") UUID procedureId) { GetVaccinationConsultationDetailsResponse vaccinationConsultationDetails = @@ -300,7 +302,7 @@ public class VaccinationConsultationController { @GetMapping(path = "/{procedureId}" + MEDICAL_HISTORY_URL) @Operation(summary = "Get medical histories for this VaccinationConsultation.") - @Transactional + @Transactional(readOnly = true) public GetMedicalHistoriesResponse getMedicalHistories( @PathVariable("procedureId") UUID procedureId) { return medicalHistoryService.getMedicalHistoriesForEmployeePortal(procedureId); @@ -325,7 +327,7 @@ public class VaccinationConsultationController { @Operation( summary = "Collect all services which have been applied to any of (and grouped by) the VaccinationConsultation's steps.") - @Transactional + @Transactional(readOnly = true) public GetStepsWithAppliedServicesResponse getStepsWithAppliedServices( @PathVariable("procedureId") UUID procedureId) { return vaccinationConsultationService.getStepsWithAppliedServices(procedureId); @@ -333,7 +335,7 @@ public class VaccinationConsultationController { @GetMapping(path = "/{procedureId}" + STATUS) @Operation(summary = "Retrieve the current state of the procedure.") - @Transactional + @Transactional(readOnly = true) public ProcedureStatusDto getStatus(@PathVariable("procedureId") UUID procedureId) { return vaccinationConsultationService.getProcedureStatus(procedureId); } @@ -349,7 +351,7 @@ public class VaccinationConsultationController { @GetMapping(path = "/{procedureId}" + INFORMATION_STATEMENT_URL) @Operation(summary = "Get information statements for this VaccinationConsultation.") - @Transactional + @Transactional(readOnly = true) public GetInformationStatementsResponse getInformationStatements( @PathVariable("procedureId") UUID procedureId) { return informationStatementService.getInformationStatementsForEmployeePortal(procedureId); 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 dd2315d70bbd6eb39f83f227ca6c2db6c3730582..b36fcd7331aa98924331add16372377d5a267dd9 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 @@ -298,16 +298,16 @@ public class VaccinationConsultationService { procedureAccessor.accessProcedure(procedureId, ProcedureAccessor.checkNotClosed); Person person = vaccinationConsultation.getRelatedPersons().getFirst(); - UUID previousFileStateId = person.getCentralFileStateId(); + UUID previousPersonFileStateId = person.getCentralFileStateId(); UUID updatedFileStateId = - personClient.syncPerson(previousFileStateId, request.referenceVersion()); + personClient.syncPerson(previousPersonFileStateId, request.referenceVersion()); person.setCentralFileStateId(updatedFileStateId); SystemProgressEntry progressEntry = SystemProgressEntryFactory.createSystemProgressEntry( PERSON_SYNCHRONIZED.name(), TriggerType.SYSTEM_AUTOMATIC); progressEntry.setProcedureId(vaccinationConsultation.getId()); - progressEntry.setPreviousFileStateId(previousFileStateId); + progressEntry.setPreviousPersonFileStateId(previousPersonFileStateId); vaccinationConsultation.addProgressEntry(progressEntry); } @@ -319,11 +319,11 @@ public class VaccinationConsultationService { } Person person = vaccinationConsultation.getRelatedPersons().getFirst(); - UUID previousFileStateId = person.getCentralFileStateId(); + UUID previousPersonFileStateId = person.getCentralFileStateId(); try { UUID patientIdFromCentralFile = - personClient.updatePersonInCentralFile(previousFileStateId, request.patient()); + personClient.updatePersonInCentralFile(previousPersonFileStateId, request.patient()); vaccinationConsultationMapper.toDomainTypePatchPerson( patientIdFromCentralFile, vaccinationConsultation); } catch (Exception e) { @@ -334,7 +334,7 @@ public class VaccinationConsultationService { SystemProgressEntryFactory.createSystemProgressEntry( PERSON_UPDATED.name(), TriggerType.SYSTEM_AUTOMATIC); progressEntry.setProcedureId(vaccinationConsultation.getId()); - progressEntry.setPreviousFileStateId(previousFileStateId); + progressEntry.setPreviousPersonFileStateId(previousPersonFileStateId); vaccinationConsultation.addProgressEntry(progressEntry); } diff --git a/backend/travel-medicine/src/main/resources/migrations/0059_differentiate_between_previous_person_and_facility_file_state.xml b/backend/travel-medicine/src/main/resources/migrations/0059_differentiate_between_previous_person_and_facility_file_state.xml new file mode 100644 index 0000000000000000000000000000000000000000..d17304f9778b68569b6151cfb178d2a36b61ebb7 --- /dev/null +++ b/backend/travel-medicine/src/main/resources/migrations/0059_differentiate_between_previous_person_and_facility_file_state.xml @@ -0,0 +1,21 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 SCOOP Software GmbH, cronn GmbH + SPDX-License-Identifier: AGPL-3.0-only +--> + +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> + <changeSet author="GA-Lotse" id="1738231823333-1"> + <renameColumn tableName="system_progress_entry" + oldColumnName="previous_file_state_id" + newColumnName="previous_person_file_state_id"/> + <addColumn tableName="system_progress_entry"> + <column name="previous_facility_file_state_id" type="UUID"/> + </addColumn> + <addUniqueConstraint columnNames="previous_facility_file_state_id" + constraintName="system_progress_entry_previous_facility_file_state_id_key" + tableName="system_progress_entry"/> + </changeSet> +</databaseChangeLog> diff --git a/backend/travel-medicine/src/main/resources/migrations/0060_oms_appointment_type_extensions.xml b/backend/travel-medicine/src/main/resources/migrations/0060_oms_appointment_type_extensions.xml new file mode 100644 index 0000000000000000000000000000000000000000..d8922b9caae415f59f75eef1627598569c318aba --- /dev/null +++ b/backend/travel-medicine/src/main/resources/migrations/0060_oms_appointment_type_extensions.xml @@ -0,0 +1,11 @@ +<?xml version="1.1" encoding="UTF-8" standalone="no"?> +<!-- + Copyright 2025 SCOOP Software GmbH, 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="1739262089245-1"> + <ext:modifyPostgresEnumType name="appointmenttype" newValues="CAN_CHILD, CONSULTATION, ENTRY_LEVEL, HIV_STI_CONSULTATION, OFFICIAL_MEDICAL_SERVICE_LONG, OFFICIAL_MEDICAL_SERVICE_SHORT, PROOF_SUBMISSION, REGULAR_EXAMINATION, RESULTS_REVIEW, SEX_WORK, SPECIAL_NEEDS, VACCINATION"/> + </changeSet> +</databaseChangeLog> diff --git a/backend/travel-medicine/src/main/resources/migrations/0061_add_shedlock.xml b/backend/travel-medicine/src/main/resources/migrations/0061_add_shedlock.xml new file mode 100644 index 0000000000000000000000000000000000000000..cb19b9a81024a4b3560f9dda56b8cc4a7588bb3d --- /dev/null +++ b/backend/travel-medicine/src/main/resources/migrations/0061_add_shedlock.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + Copyright 2025 SCOOP Software GmbH, cronn GmbH + SPDX-License-Identifier: AGPL-3.0-only +--> + +<databaseChangeLog + xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog + http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd"> + <changeSet author="GA-Lotse" id="1729865197316-1"> + <createTable tableName="shedlock"> + <column name="name" type="VARCHAR(64)"> + <constraints nullable="false" primaryKey="true" primaryKeyName="pk_shedlock"/> + </column> + <column name="lock_until" type="TIMESTAMP WITHOUT TIME ZONE"> + <constraints nullable="false"/> + </column> + <column name="locked_at" type="TIMESTAMP WITHOUT TIME ZONE"> + <constraints nullable="false"/> + </column> + <column name="locked_by" type="VARCHAR(255)"> + <constraints nullable="false"/> + </column> + </createTable> + </changeSet> +</databaseChangeLog> diff --git a/backend/travel-medicine/src/main/resources/migrations/changelog.xml b/backend/travel-medicine/src/main/resources/migrations/changelog.xml index 2788bc867f320d1dbac35f2306c77b1b91db6042..e856a0450a5a192f6f5db4711a3ac9d010613577 100644 --- a/backend/travel-medicine/src/main/resources/migrations/changelog.xml +++ b/backend/travel-medicine/src/main/resources/migrations/changelog.xml @@ -66,5 +66,8 @@ <include file="migrations/0056_add_previous_file_state_id_to_system_progress_entry.xml"/> <include file="migrations/0057_add_auditlog_entry.xml"/> <include file="migrations/0058_convert_duration_columns_to_interval.xml"/> + <include file="migrations/0059_differentiate_between_previous_person_and_facility_file_state.xml"/> + <include file="migrations/0060_oms_appointment_type_extensions.xml"/> + <include file="migrations/0061_add_shedlock.xml"/> </databaseChangeLog> diff --git a/buildSrc/src/main/groovy/de/eshg/frontend/TypescriptDefaults.groovy b/buildSrc/src/main/groovy/de/eshg/frontend/TypescriptDefaults.groovy index de5ee7c11699b760fb1b78b8ecfeb74d310fdad3..621ee9a6bb1f6aa952f4fa595271bd73567e4a23 100644 --- a/buildSrc/src/main/groovy/de/eshg/frontend/TypescriptDefaults.groovy +++ b/buildSrc/src/main/groovy/de/eshg/frontend/TypescriptDefaults.groovy @@ -7,7 +7,12 @@ import org.gradle.api.provider.ListProperty class TypescriptDefaults { - private static final List<String> DEFAULT_EXCLUDES = ["node_modules", 'build', '.gradle'] + private static final List<String> DEFAULT_EXCLUDES = [ + 'node_modules', + 'build', + '.gradle', + 'data/test' + ] static List<String> getAllExcludes(ListProperty<String> additionalExcludes) { return DEFAULT_EXCLUDES + additionalExcludes.getOrElse([]) diff --git a/buildSrc/src/main/groovy/lib-package.gradle b/buildSrc/src/main/groovy/lib-package.gradle index 40afba5f5b27174669f34217b7edf1b8edaaf889..c06a66a4eb667ed645016a748be09797c398dc71 100644 --- a/buildSrc/src/main/groovy/lib-package.gradle +++ b/buildSrc/src/main/groovy/lib-package.gradle @@ -8,6 +8,7 @@ plugins { prettier { include = ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.json'] + additionalExcludes = ['data/test'] } eslint { diff --git a/buildSrc/src/main/groovy/next-app.gradle b/buildSrc/src/main/groovy/next-app.gradle index ed3392ba2ef74d70ae22bcc1d24f99994056dd7a..0618a24474cbb867bf3d82245da2214908127fa6 100644 --- a/buildSrc/src/main/groovy/next-app.gradle +++ b/buildSrc/src/main/groovy/next-app.gradle @@ -24,7 +24,7 @@ def dockerBuildDir = layout.buildDirectory.dir('docker') prettier { include = ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.json'] - additionalExcludes = ['next-env.d.ts', '.next'] + additionalExcludes = ['next-env.d.ts', '.next', 'data/test'] } eslint { @@ -106,6 +106,9 @@ tasks.register('testCoverage', PnpmTask) { args = ['vitest', 'run', '--coverage', '--passWithNoTests', '--silent'] } +tasks.named('findUnusedValidationFiles').configure { + mustRunAfter 'testCoverage' +} tasks.register('analyzeBundle', PnpmTask) { dependsOn 'prepareEnvironment' diff --git a/buildSrc/src/main/groovy/vitest.gradle b/buildSrc/src/main/groovy/vitest.gradle index 7275e159c77a3b16a2313740d1395281ea2def07..ef5fe126f15fac093e8fe5e0b4938c7a39e668a1 100644 --- a/buildSrc/src/main/groovy/vitest.gradle +++ b/buildSrc/src/main/groovy/vitest.gradle @@ -1,4 +1,5 @@ import com.github.gradle.node.pnpm.task.PnpmTask +import de.eshg.frontend.FindUnusedValidationFiles plugins { id 'workspace-package' @@ -12,16 +13,18 @@ def testConfigFiles = [ "${projectDir}/vitest.config.ts" ] def testSrcDir = "${projectDir}/src" +def validationFilesDir = project.layout.projectDirectory.dir('data/test') -tasks.named('check').configure { dependsOn 'test' } - -tasks.register('test', PnpmTask) { +def test = tasks.register('test', PnpmTask) { group = 'verification' dependsOn 'prepareEnvironment' environment = testEnvironment inputs.files testConfigFiles inputs.dir testSrcDir - outputs.upToDateWhen { true } + if (validationFilesDir.asFile.exists()) { + inputs.dir validationFilesDir + outputs.dir validationFilesDir + } // Pass with no tests to avoid writing a dummy test within the new portal tests. // This parameter can be removed once we have tests in each portal project. args = ['vitest', 'run', '--passWithNoTests', '--silent'] @@ -33,3 +36,14 @@ tasks.register('testWatch', PnpmTask) { environment = testEnvironment args = ['vitest', 'watch'] } + +def findUnusedValidationFiles = tasks.register('findUnusedValidationFiles', FindUnusedValidationFiles) { + group = 'verification' + dependsOn test + onlyIf { validationFilesDir.asFile.exists() } +} + +tasks.named('check').configure { + dependsOn test + dependsOn findUnusedValidationFiles +} diff --git a/citizen-portal/gradleDependencies.json b/citizen-portal/gradleDependencies.json index d06419508bcfd1ed35341ee1d63df57d87347f31..7b85a2fe6b879f2047dbc48feb29a81d1703c71a 100644 --- a/citizen-portal/gradleDependencies.json +++ b/citizen-portal/gradleDependencies.json @@ -3,11 +3,13 @@ ":base-api", ":lib-portal", ":lib-procedures-api", + ":lib-vitest", ":measles-protection-api", ":medical-registry-api", ":official-medical-service-api", ":opendata-api", ":school-entry-api", + ":sti-protection-api", ":travel-medicine-api" ] } diff --git a/citizen-portal/package.json b/citizen-portal/package.json index 5c8b7d22d518d75db670841f99e2d8502cf32ca2..009be8886e3b0fa714784350518e48786c769bcb 100644 --- a/citizen-portal/package.json +++ b/citizen-portal/package.json @@ -14,6 +14,7 @@ "@eshg/official-medical-service-api": "workspace:*", "@eshg/opendata-api": "workspace:*", "@eshg/school-entry-api": "workspace:*", + "@eshg/sti-protection-api": "workspace:*", "@eshg/travel-medicine-api": "workspace:*", "@fontsource/poppins": "catalog:joy", "@fullcalendar/core": "catalog:fullcalendar", @@ -41,6 +42,7 @@ "valibot": "catalog:common" }, "devDependencies": { + "@eshg/lib-vitest": "workspace:*", "@eslint/compat": "catalog:eslint", "@eslint/eslintrc": "catalog:eslint", "@next/bundle-analyzer": "catalog:next", diff --git a/citizen-portal/src/app/[lang]/(privatpersonen)/amtsaerztlicherdienst/page.tsx b/citizen-portal/src/app/[lang]/(privatpersonen)/amtsaerztlicherdienst/page.tsx index 0f663fcfb691e59807cc2cede1aecb3077d0e3ef..d828013f62a4a98d6dbd4ee22a97632d9e8a6b2b 100644 --- a/citizen-portal/src/app/[lang]/(privatpersonen)/amtsaerztlicherdienst/page.tsx +++ b/citizen-portal/src/app/[lang]/(privatpersonen)/amtsaerztlicherdienst/page.tsx @@ -12,11 +12,17 @@ import { LandingpageContent } from "@/lib/businessModules/officialMedicalService import { LandingpageSidePanel } from "@/lib/businessModules/officialMedicalService/components/landing/LandingpageSidePanel"; import { useTranslation } from "@/lib/i18n/client"; import { PageContent } from "@/lib/shared/components/layout/PageContent"; -import { TwoColumnGrid } from "@/lib/shared/components/layout/grid"; +import { + OneColumnGrid, + TwoColumnGrid, +} from "@/lib/shared/components/layout/grid"; import { PageLayout, PageTitle } from "@/lib/shared/components/layout/page"; +import { useIsMobile } from "@/lib/shared/hooks/useIsMobile"; export default function CitizenOmsEntryPage() { const { t } = useTranslation(["officialMedicalService/landing"]); + const isMobile = useIsMobile(); + const [{ data: departmentInfo }] = useSuspenseQueries({ queries: [useGetDepartmentInfoQuery()], }); @@ -25,10 +31,19 @@ export default function CitizenOmsEntryPage() { <PageLayout banner="private"> <PageContent> <PageTitle>{t("pageTitle")}</PageTitle> - <TwoColumnGrid - content={<LandingpageContent departmentInfo={departmentInfo} />} - sidePanel={<LandingpageSidePanel />} - /> + {isMobile ? ( + <OneColumnGrid + contentTop={<LandingpageSidePanel />} + contentCenter={ + <LandingpageContent departmentInfo={departmentInfo} /> + } + /> + ) : ( + <TwoColumnGrid + content={<LandingpageContent departmentInfo={departmentInfo} />} + sidePanel={<LandingpageSidePanel />} + /> + )} </PageContent> </PageLayout> ); diff --git a/citizen-portal/src/app/[lang]/(privatpersonen)/amtsaerztlicherdienst/termin/page.tsx b/citizen-portal/src/app/[lang]/(privatpersonen)/amtsaerztlicherdienst/termin/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ea91a1c7a05539455ba3c95ba7d40a4de87d4a4b --- /dev/null +++ b/citizen-portal/src/app/[lang]/(privatpersonen)/amtsaerztlicherdienst/termin/page.tsx @@ -0,0 +1,20 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +"use client"; + +import { AppointmentForm } from "@/lib/businessModules/officialMedicalService/components/appointment/AppointmentForm"; +import { PageContent } from "@/lib/shared/components/layout/PageContent"; +import { PageLayout } from "@/lib/shared/components/layout/page"; + +export default function CitizenOmsAppointmentPage() { + return ( + <PageLayout> + <PageContent> + <AppointmentForm /> + </PageContent> + </PageLayout> + ); +} diff --git a/citizen-portal/src/app/[lang]/(privatpersonen)/sexuelle-gesundheit/sexarbeit/page.tsx b/citizen-portal/src/app/[lang]/(privatpersonen)/sexuelle-gesundheit/sexarbeit/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1b70ba695cfd14bc1653c02cfb9b59e9f248152c --- /dev/null +++ b/citizen-portal/src/app/[lang]/(privatpersonen)/sexuelle-gesundheit/sexarbeit/page.tsx @@ -0,0 +1,31 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +"use client"; + +import { ApiConcern } from "@eshg/sti-protection-api"; + +import { LandingpageContent } from "@/lib/businessModules/stiProtection/pages/landingpage/LandingpageContent"; +import { LandingpageSidePanel } from "@/lib/businessModules/stiProtection/pages/landingpage/LandingpageSidePanel"; +import { useTranslation } from "@/lib/i18n/client"; +import { PageContent } from "@/lib/shared/components/layout/PageContent"; +import { TwoColumnGrid } from "@/lib/shared/components/layout/grid"; +import { PageLayout, PageTitle } from "@/lib/shared/components/layout/page"; + +export default function CitizenSexWorkPage() { + const { t } = useTranslation(["stiProtection/overview"]); + + return ( + <PageLayout banner="private"> + <PageContent> + <PageTitle>{t("page_title_sex_work")}</PageTitle> + <TwoColumnGrid + content={<LandingpageContent concern={ApiConcern.SexWork} />} + sidePanel={<LandingpageSidePanel />} + /> + </PageContent> + </PageLayout> + ); +} diff --git a/citizen-portal/src/app/[lang]/(privatpersonen)/sexuelle-gesundheit/sti-beratung/page.tsx b/citizen-portal/src/app/[lang]/(privatpersonen)/sexuelle-gesundheit/sti-beratung/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7ba70484ec3d5f0ce8528011a3715db69f5b34e3 --- /dev/null +++ b/citizen-portal/src/app/[lang]/(privatpersonen)/sexuelle-gesundheit/sti-beratung/page.tsx @@ -0,0 +1,33 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +"use client"; + +import { ApiConcern } from "@eshg/sti-protection-api"; + +import { LandingpageContent } from "@/lib/businessModules/stiProtection/pages/landingpage/LandingpageContent"; +import { LandingpageSidePanel } from "@/lib/businessModules/stiProtection/pages/landingpage/LandingpageSidePanel"; +import { useTranslation } from "@/lib/i18n/client"; +import { PageContent } from "@/lib/shared/components/layout/PageContent"; +import { TwoColumnGrid } from "@/lib/shared/components/layout/grid"; +import { PageLayout, PageTitle } from "@/lib/shared/components/layout/page"; + +export default function CitizenStiConsultationPage() { + const { t } = useTranslation(["stiProtection/overview"]); + + return ( + <PageLayout banner="private"> + <PageContent> + <PageTitle>{t("page_title_sti_consultation")}</PageTitle> + <TwoColumnGrid + content={ + <LandingpageContent concern={ApiConcern.HivStiConsultation} /> + } + sidePanel={<LandingpageSidePanel />} + /> + </PageContent> + </PageLayout> + ); +} diff --git a/citizen-portal/src/lib/baseModule/moduleRegister/navigationItemsResolver.tsx b/citizen-portal/src/lib/baseModule/moduleRegister/navigationItemsResolver.tsx index 0f3f3fdd061b5d7f30cb14e99bbb252b56ee9150..4fb4a0e6aaede9de373fc5afc7e8390922cae634 100644 --- a/citizen-portal/src/lib/baseModule/moduleRegister/navigationItemsResolver.tsx +++ b/citizen-portal/src/lib/baseModule/moduleRegister/navigationItemsResolver.tsx @@ -20,6 +20,7 @@ import { useCitizenNavigationItems as useSchoolEntryCitizenNavigationItems, useOrganizationNavigationItems as useSchoolEntryOrganizationNavigationItems, } from "@/lib/businessModules/schoolEntry/shared/navigationItems"; +import { useCitizenNavigationItems as useStiProtectionCitizenNavigationItems } from "@/lib/businessModules/stiProtection/shared/navigationItems"; import { useCitizenNavigationItems as useTravelMedicineCitizenNavigationItems, useOrganizationNavigationItems as useTravelMedicineOrganizationNavigationItems, @@ -37,6 +38,8 @@ export function useResolveCitizenNavigationItems(): NavigationItem[] { const medicalRegistryCitizenNavigationItems = useMedicalRegistryCitizenNavigationItems(); const navigationItems = useBaseCitizenNavigationItems(); + const stiProtectionCitizenNavigationItems = + useStiProtectionCitizenNavigationItems(); if (hasBusinessModule(ApiBusinessModule.SchoolEntry)) { navigationItems.push(...schoolEntryCitizenNavigationItems); @@ -50,6 +53,9 @@ export function useResolveCitizenNavigationItems(): NavigationItem[] { if (hasBusinessModule(ApiBusinessModule.OfficialMedicalService)) { navigationItems.push(...officialMedicalServcieNavigationItems); } + if (hasBusinessModule(ApiBusinessModule.StiProtection)) { + navigationItems.push(...stiProtectionCitizenNavigationItems); + } return navigationItems; } diff --git a/citizen-portal/src/lib/businessModules/measlesProtection/components/reportCase/ReportCaseOverview.tsx b/citizen-portal/src/lib/businessModules/measlesProtection/components/reportCase/ReportCaseOverview.tsx index 21b5593ea5c606175d089885da5788f8f4b47a57..786b4ade877290c9eae498e6617d6cba0efbd5ec 100644 --- a/citizen-portal/src/lib/businessModules/measlesProtection/components/reportCase/ReportCaseOverview.tsx +++ b/citizen-portal/src/lib/businessModules/measlesProtection/components/reportCase/ReportCaseOverview.tsx @@ -5,10 +5,6 @@ "use client"; -import { - ApiReportingReason, - ApiRoleStatus, -} from "@eshg/measles-protection-api"; import { DeleteOutline, EditOutlined } from "@mui/icons-material"; import { Accordion, @@ -203,20 +199,23 @@ export function ReportCaseOverview({ onCancel, sx }: ReportCaseOverviewProps) { )} <DetailsField label={t("affectedPerson.fields.roleStatus")} - value={t( - roleStatusNames[ - affectedPerson.roleStatus as ApiRoleStatus - ], - )} + value={ + affectedPerson.roleStatus + ? t(roleStatusNames[affectedPerson.roleStatus]) + : "" + } /> <DetailsField label={t("affectedPerson.fields.reportingReason")} - value={t( - reportingReasonNames[ - affectedPerson.reportData - .reportingReason as ApiReportingReason - ], - )} + value={ + affectedPerson.reportData.reportingReason + ? t( + reportingReasonNames[ + affectedPerson.reportData.reportingReason + ], + ) + : "" + } /> </Grid> </Sheet> diff --git a/citizen-portal/src/lib/businessModules/measlesProtection/locales/de/forms.json b/citizen-portal/src/lib/businessModules/measlesProtection/locales/de/forms.json index dac53a43d2e262708ed8621d16fccbcce431eb56..cfa5ec14d819a9ae9d7b534656c45c42f47198ed 100644 --- a/citizen-portal/src/lib/businessModules/measlesProtection/locales/de/forms.json +++ b/citizen-portal/src/lib/businessModules/measlesProtection/locales/de/forms.json @@ -34,7 +34,7 @@ } }, "overview": { - "title": "Zu meldenden Personen", + "title": "Zu meldende Personen", "submit_one": "Fall melden", "submit_other": "Fälle melden", "reportAdditionalPerson": "Weitere Person melden" diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/api/mutations/citizenPublicApi.ts b/citizen-portal/src/lib/businessModules/officialMedicalService/api/mutations/citizenPublicApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4e06b883499b343b0180cf9efe68fb6a04aac37 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/api/mutations/citizenPublicApi.ts @@ -0,0 +1,29 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { unwrapRawResponse } from "@eshg/lib-portal/api/unwrapRawResponse"; +import { useHandledMutation } from "@eshg/lib-portal/api/useHandledMutation"; +import { useSnackbar } from "@eshg/lib-portal/components/snackbar/SnackbarProvider"; +import { PostCitizenProcedureRequest } from "@eshg/official-medical-service-api"; + +import { useCitizenPublicApi } from "@/lib/businessModules/officialMedicalService/api/clients"; +import { useTranslation } from "@/lib/i18n/client"; + +export function usePostCitizenProcedure() { + const citizenPublicApi = useCitizenPublicApi(); + const snackbar = useSnackbar(); + const { t } = useTranslation(["officialMedicalService/appointment"]); + + return useHandledMutation({ + mutationFn: (request: PostCitizenProcedureRequest) => { + return citizenPublicApi + .postCitizenProcedureRaw(request) + .then(unwrapRawResponse); + }, + onSuccess: () => { + snackbar.confirmation(t("common.snackbar.success")); + }, + }); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/api/queries/citizenPublicApi.ts b/citizen-portal/src/lib/businessModules/officialMedicalService/api/queries/citizenPublicApi.ts index 280fe9f7107cce5c81e1ef4b91609f38877185a7..9115a116ffc5828042f5722cc576730b17837642 100644 --- a/citizen-portal/src/lib/businessModules/officialMedicalService/api/queries/citizenPublicApi.ts +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/api/queries/citizenPublicApi.ts @@ -23,3 +23,15 @@ export function useGetOpeningHoursQuery() { queryFn: () => departmentApi.getOpeningHours(), }); } + +export function useGetFreeAppointmentsForCitizen() { + const citizenPublicApi = useCitizenPublicApi(); + + return queryOptions({ + queryKey: citizenPublicApiQueryKey(["getFreeAppointmentsForCitizen"]), + queryFn: () => + citizenPublicApi.getFreeAppointmentsForCitizen( + "OFFICIAL_MEDICAL_SERVICE_SHORT", + ), + }); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/AppointmentForm.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/AppointmentForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..49bd1c1a7873285f3788037c867ab8842a6e0798 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/AppointmentForm.tsx @@ -0,0 +1,138 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { FormPlus } from "@eshg/lib-portal/components/form/FormPlus"; +import { + MultiStepForm, + StepFactory, +} from "@eshg/lib-portal/components/form/MultiStepForm"; +import { OptionalFieldValue } from "@eshg/lib-portal/types/form"; +import { + ApiAppointment, + ApiSalutation, + ApiTitle, + PostCitizenProcedureRequest, +} from "@eshg/official-medical-service-api"; +import { Formik } from "formik"; +import { useRouter } from "next/navigation"; + +import { usePostCitizenProcedure } from "@/lib/businessModules/officialMedicalService/api/mutations/citizenPublicApi"; +import { AppointmentFormSidePanel } from "@/lib/businessModules/officialMedicalService/components/appointment/AppointmentFormSidePanel"; +import { AppointmentStepWrapper } from "@/lib/businessModules/officialMedicalService/components/appointment/AppointmentStepWrapper"; +import { ConcernStep } from "@/lib/businessModules/officialMedicalService/components/appointment/steps/ConcernStep"; +import { DocumentAndPersonalDataStep } from "@/lib/businessModules/officialMedicalService/components/appointment/steps/DocumentAndPersonalDataStep"; +import { SummaryStep } from "@/lib/businessModules/officialMedicalService/components/appointment/steps/SummaryStep"; +import { DepartmentContextProvider } from "@/lib/businessModules/officialMedicalService/shared/contexts/DepartmentContext"; +import { mapToPostCitizenProcedureRequest } from "@/lib/businessModules/officialMedicalService/shared/helpers"; +import { useCitizenRoutes } from "@/lib/businessModules/officialMedicalService/shared/routes"; +import { MultiStepFormTitle } from "@/lib/businessModules/travelMedicine/components/shared/components/multiStepForm/MultiStepFormWrapper"; +import { useTranslation } from "@/lib/i18n/client"; +import { TwoColumnGrid } from "@/lib/shared/components/layout/grid"; + +export interface AppointmentFormValues { + files: File[]; + affectedPerson: { + salutation: OptionalFieldValue<ApiSalutation>; + title: OptionalFieldValue<ApiTitle>; + firstName: string; + lastName: string; + dateOfBirth: string; + emailAddresses: string; + phoneNumbers?: string; + contactAddress: { + street: string; + houseNumber: string; + addressAddition?: string; + postalCode: string; + city: string; + }; + }; + concern: string; + appointment?: ApiAppointment; + confirmOnlineServices: boolean; + confirmPrivacyNotice: boolean; + confirmPrivacyPolicy: boolean; +} + +const STEPS: StepFactory<AppointmentFormValues>[] = [ + () => <ConcernStep />, + AppointmentStepWrapper, + DocumentAndPersonalDataStep, + SummaryStep, +]; + +const INITIAL_VALUES: AppointmentFormValues = { + concern: "", + affectedPerson: { + salutation: "", + title: "", + firstName: "", + lastName: "", + dateOfBirth: "", + emailAddresses: "", + phoneNumbers: "", + contactAddress: { + street: "", + houseNumber: "", + addressAddition: "", + postalCode: "", + city: "", + }, + }, + files: [], + confirmOnlineServices: false, + confirmPrivacyNotice: false, + confirmPrivacyPolicy: false, + appointment: undefined, +}; + +export function AppointmentForm() { + const { t } = useTranslation(["officialMedicalService/appointment"]); + const router = useRouter(); + const citizenRoutes = useCitizenRoutes(); + const postCitizenProcedure = usePostCitizenProcedure(); + + async function handleSubmit(values: AppointmentFormValues) { + const request: PostCitizenProcedureRequest = + mapToPostCitizenProcedureRequest(values); + + await postCitizenProcedure.mutateAsync(request, { + onSuccess: () => router.push(citizenRoutes.overview), + }); + } + + return ( + <DepartmentContextProvider> + <MultiStepForm<AppointmentFormValues> steps={STEPS}> + {({ Outlet, currentStep, totalSteps }) => ( + <> + <MultiStepFormTitle + title={t("common.title")} + stepperTitle={t("common.stepTitle", { + currentStepIndex: currentStep, + totalSteps: totalSteps, + })} + withLogoutButton={false} + /> + <Formik initialValues={INITIAL_VALUES} onSubmit={handleSubmit}> + {(formikProps) => ( + <FormPlus> + {Outlet.name !== "AppointmentStepWrapper" ? ( + <TwoColumnGrid + content={<Outlet {...formikProps} />} + sidePanel={<AppointmentFormSidePanel />} + /> + ) : ( + <AppointmentStepWrapper /> + )} + </FormPlus> + )} + </Formik> + </> + )} + </MultiStepForm> + </DepartmentContextProvider> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/AppointmentFormSidePanel.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/AppointmentFormSidePanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..85482613497e3b55c60dda1bb17edaf2033cd297 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/AppointmentFormSidePanel.tsx @@ -0,0 +1,51 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useMultiStepForm } from "@eshg/lib-portal/components/form/MultiStepForm"; + +import { ConfirmationSection } from "@/lib/businessModules/officialMedicalService/components/appointment/steps/ConfirmationSection"; +import { OverviewSection } from "@/lib/businessModules/officialMedicalService/components/appointment/steps/OverviewSection"; +import { MultiStepFormButtonBar } from "@/lib/businessModules/officialMedicalService/shared/MultiStepFormButtonBar"; +import { useCitizenRoutes } from "@/lib/businessModules/officialMedicalService/shared/routes"; +import { useTranslation } from "@/lib/i18n/client"; +import { ContentSheet } from "@/lib/shared/components/layout/contentSheet"; + +export function AppointmentFormSidePanel() { + const { t } = useTranslation(["officialMedicalService/appointment"]); + const citizenRoutes = useCitizenRoutes(); + + const { currentStep, totalSteps } = useMultiStepForm(); + + return ( + <ContentSheet> + {currentStep !== totalSteps && ( + <OverviewSection + buttonBar={ + <MultiStepFormButtonBar + href={citizenRoutes.overview} + backLabel={t("overview.goBack")} + cancelLabel={t("overview.cancel")} + forwardLabel={t("overview.goForward")} + /> + } + /> + )} + + {currentStep === totalSteps && ( + <ConfirmationSection + buttonBar={ + <MultiStepFormButtonBar + href={citizenRoutes.overview} + backLabel={t("overview.goBack")} + cancelLabel={t("overview.cancel")} + forwardLabel={t("overview.goForward")} + submitLabel={t("confirmation.submit")} + /> + } + /> + )} + </ContentSheet> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/AppointmentStepWrapper.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/AppointmentStepWrapper.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a5b299465d6779e344d93b0564833335f2204c53 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/AppointmentStepWrapper.tsx @@ -0,0 +1,63 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useSuspenseQueries } from "@tanstack/react-query"; +import { isAfter, isEqual } from "date-fns"; +import { useFormikContext } from "formik"; +import { useEffect, useMemo, useState } from "react"; +import { isDefined } from "remeda"; + +import { useGetFreeAppointmentsForCitizen } from "@/lib/businessModules/officialMedicalService/api/queries/citizenPublicApi"; +import { AppointmentFormValues } from "@/lib/businessModules/officialMedicalService/components/appointment/AppointmentForm"; +import { NoAppointmentCard } from "@/lib/businessModules/officialMedicalService/components/appointment/NoAppointmentCard"; +import { AppointmentStep } from "@/lib/businessModules/officialMedicalService/components/appointment/steps/AppointmentStep"; +import { TwoColumnGrid } from "@/lib/shared/components/layout/grid"; + +import { AppointmentFormSidePanel } from "./AppointmentFormSidePanel"; + +function isDateCurrentDateOrGreater(date: Date) { + const now = new Date(); + return isEqual(date, now) || isAfter(date, now); //filter out dates before now +} + +export function AppointmentStepWrapper() { + const { setFieldValue } = useFormikContext<AppointmentFormValues>(); + const [{ data: freeAppointments }] = useSuspenseQueries({ + queries: [useGetFreeAppointmentsForCitizen()], + }); + const [isInitialDate, setIsInitialDate] = useState(false); + + const filteredAppointments = useMemo( + () => + freeAppointments.appointments.filter((appointment) => + isDateCurrentDateOrGreater(appointment.start), + ), + [freeAppointments], + ); + + useEffect(() => { + const firstAppointment = filteredAppointments[0]; + if (isDefined(firstAppointment)) { + void (async () => { + await setFieldValue("appointment", { + start: firstAppointment.start, + end: firstAppointment.end, + }); + setIsInitialDate(true); + })(); + } + }, [filteredAppointments, setFieldValue]); + + return filteredAppointments.length > 0 ? ( + isInitialDate && ( + <TwoColumnGrid + content={<AppointmentStep appointments={filteredAppointments} />} + sidePanel={<AppointmentFormSidePanel />} + /> + ) + ) : ( + <NoAppointmentCard /> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/NoAppointmentCard.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/NoAppointmentCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d6a0a5cd7779946f36e3d926106b55cec897e872 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/NoAppointmentCard.tsx @@ -0,0 +1,39 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { InternalLinkButton } from "@eshg/lib-portal/components/navigation/InternalLinkButton"; +import { DateRangeOutlined } from "@mui/icons-material"; +import { Stack, Typography } from "@mui/joy"; + +import { useCitizenRoutes } from "@/lib/businessModules/officialMedicalService/shared/routes"; +import { useTranslation } from "@/lib/i18n/client"; +import { ContentSheet } from "@/lib/shared/components/layout/contentSheet"; + +export function NoAppointmentCard() { + const { t } = useTranslation(["officialMedicalService/appointment"]); + const citizenRoutes = useCitizenRoutes(); + + return ( + <ContentSheet> + <Typography level="h2">{t("appointment.title")}</Typography> + <Stack + direction="column" + justifyContent="center" + alignItems="center" + spacing={3} + sx={{ padding: 2 }} + > + <DateRangeOutlined sx={{ fontSize: 70, color: "#94beff" }} /> + <Typography sx={{ fontWeight: "bold" }}> + {t("appointment.appointmentPicker.noAppointmentsAvailable")} + </Typography> + <Typography>{t("appointment.appointmentPicker.tryLater")}</Typography> + <InternalLinkButton variant="solid" href={citizenRoutes.overview}> + {t("appointment.backToOverview")} + </InternalLinkButton> + </Stack> + </ContentSheet> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/AffectedPersonForm.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/AffectedPersonForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cafb9a564049e00e77cc79c9e1531463ed197172 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/AffectedPersonForm.tsx @@ -0,0 +1,139 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { DateField } from "@eshg/lib-portal/components/formFields/DateField"; +import { EmailField } from "@eshg/lib-portal/components/formFields/EmailField"; +import { InputField } from "@eshg/lib-portal/components/formFields/InputField"; +import { PhoneNumberField } from "@eshg/lib-portal/components/formFields/PhoneNumberField"; +import { SelectField } from "@eshg/lib-portal/components/formFields/SelectField"; +import { + validateLength, + validatePastOrTodayDate, +} from "@eshg/lib-portal/helpers/validators"; +import { ApiAffectedPerson } from "@eshg/official-medical-service-api"; +import { Grid } from "@mui/joy"; + +import { + salutationOptions, + titleOptions, +} from "@/lib/businessModules/measlesProtection/shared/translations"; +import { FormSheetTitle } from "@/lib/businessModules/travelMedicine/components/shared/components/FormSheet"; +import { CheckboxField } from "@/lib/businessModules/travelMedicine/components/shared/components/formField/CheckboxField"; +import { useTranslation } from "@/lib/i18n/client"; +import { byBreakpoint } from "@/lib/shared/breakpoints"; +import { ContentSheet } from "@/lib/shared/components/layout/contentSheet"; +import { createFieldNameMapper } from "@/lib/shared/helpers/form"; +import { validateEmail } from "@/lib/shared/helpers/validators"; + +export function AffectedPersonForm(props: { name: string }) { + const { t } = useTranslation(["officialMedicalService/appointment"]); + const fieldName = createFieldNameMapper<ApiAffectedPerson>(props.name); + + return ( + <ContentSheet> + <FormSheetTitle requiredTitle={t("common.requiredTitle")}> + {t("affectedPerson.title")} + </FormSheetTitle> + <Grid container spacing={2} sx={{ flexGrow: 1 }}> + <Grid xxs={12} xs={6}> + <SelectField + name={fieldName("salutation")} + label={t("affectedPerson.fields.salutation")} + options={salutationOptions(t)} + /> + </Grid> + <Grid xxs={12} xs={6}> + <SelectField + name={fieldName("title")} + label={t("affectedPerson.fields.title")} + options={titleOptions(t)} + /> + </Grid> + <Grid {...byBreakpoint({ mobile: 12, desktop: 12 })}> + <InputField + name={fieldName("firstName")} + label={t("affectedPerson.fields.firstName")} + required={t("affectedPerson.fields.firstName_required")} + /> + </Grid> + <Grid {...byBreakpoint({ mobile: 12, desktop: 12 })}> + <InputField + name={fieldName("lastName")} + label={t("affectedPerson.fields.lastName")} + required={t("affectedPerson.fields.lastName_required")} + /> + </Grid> + <Grid {...byBreakpoint({ mobile: 12, desktop: 12 })}> + <DateField + name={fieldName("dateOfBirth")} + label={t("affectedPerson.fields.dateOfBirth")} + required={t("affectedPerson.fields.dateOfBirth_required")} + validate={validatePastOrTodayDate} + /> + </Grid> + <Grid {...byBreakpoint({ mobile: 12, desktop: 10 })}> + <InputField + name={`${fieldName("contactAddress")}.street`} + label={t("affectedPerson.fields.contactAddress.street")} + required={t("affectedPerson.fields.contactAddress.street_required")} + /> + </Grid> + <Grid {...byBreakpoint({ mobile: 12, desktop: 2 })}> + <InputField + name={`${fieldName("contactAddress")}.houseNumber`} + label={t("affectedPerson.fields.contactAddress.houseNumber")} + required={t( + "affectedPerson.fields.contactAddress.houseNumber_required", + )} + /> + </Grid> + <Grid {...byBreakpoint({ mobile: 12, desktop: 12 })}> + <InputField + name={`${fieldName("contactAddress")}.addressAddition`} + label={t("affectedPerson.fields.contactAddress.addressAddition")} + /> + </Grid> + <Grid {...byBreakpoint({ mobile: 12, desktop: 2 })}> + <InputField + name={`${fieldName("contactAddress")}.postalCode`} + label={t("affectedPerson.fields.contactAddress.postalCode")} + required={t( + "affectedPerson.fields.contactAddress.postalCode_required", + )} + /> + </Grid> + <Grid {...byBreakpoint({ mobile: 12, desktop: 10 })}> + <InputField + name={`${fieldName("contactAddress")}.city`} + label={t("affectedPerson.fields.contactAddress.city")} + required={t("affectedPerson.fields.contactAddress.city_required")} + /> + </Grid> + <Grid {...byBreakpoint({ mobile: 12, desktop: 12 })}> + <PhoneNumberField + name={fieldName("phoneNumbers")} + label={t("affectedPerson.fields.phoneNumbers")} + validate={validateLength(1, 23)} + /> + </Grid> + <Grid {...byBreakpoint({ mobile: 12, desktop: 12 })}> + <EmailField + name={fieldName("emailAddresses")} + label={t("affectedPerson.fields.emailAddresses")} + required={t("affectedPerson.fields.emailAddresses_required")} + validate={validateEmail} + /> + </Grid> + <Grid {...byBreakpoint({ mobile: 12, desktop: 12 })}> + <CheckboxField + name={"confirmOnlineServices"} + label={t("affectedPerson.fields.confirmOnlineServices")} + required={t("affectedPerson.fields.confirmOnlineServices_required")} + /> + </Grid> + </Grid> + </ContentSheet> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/AppointmentStep.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/AppointmentStep.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d5c2657fb0046ae6808cf4f05fbef1ac397e136a --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/AppointmentStep.tsx @@ -0,0 +1,236 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { AppointmentListProps } from "@eshg/lib-portal/components/formFields/appointmentPicker/AppointmentListForDate"; +import { + Appointment, + AppointmentPickerField, + AppointmentPickerLayoutProps, +} from "@eshg/lib-portal/components/formFields/appointmentPicker/AppointmentPickerField"; +import { timeForm } from "@eshg/lib-portal/components/formFields/appointmentPicker/helpers"; +import { ApiAppointment } from "@eshg/official-medical-service-api"; +import { + Box, + Chip, + List, + ListItem, + Radio, + RadioGroup, + Sheet, + Stack, + Typography, + useTheme, +} from "@mui/joy"; +import { isEqual } from "date-fns"; +import { useId, useState } from "react"; + +import { useTranslation } from "@/lib/i18n/client"; +import { ContentSheet } from "@/lib/shared/components/layout/contentSheet"; +import { useIsMobile } from "@/lib/shared/hooks/useIsMobile"; + +interface AppointmentStepProps { + appointments: ApiAppointment[]; +} + +export function AppointmentStep({ + appointments, +}: Readonly<AppointmentStepProps>) { + const { t } = useTranslation(["officialMedicalService/appointment"]); + const [month, setMonth] = useState<Date>(new Date()); + + return ( + <ContentSheet> + <Typography level="h2">{t("appointment.title")}</Typography> + <AppointmentPickerField + name="appointment" + currentMonth={month} + setCurrentMonth={setMonth} + monthAppointments={appointments} + labels={{ + requiredAppointment: t( + "appointment.appointmentPicker.requiredAppointment", + ), + requiredDay: t("appointment.appointmentPicker.requiredDay"), + monthSelection: t("appointment.appointmentPicker.monthSelection"), + nextMonth: t("appointment.appointmentPicker.nextMonth"), + prevMonth: t("appointment.appointmentPicker.prevMonth"), + listLabel: t("appointment.appointmentPicker.listLabel"), + }} + isAppointmentEqual={(apt1: ApiAppointment, apt2: ApiAppointment) => + isEqual(apt1.start, apt2.start) && isEqual(apt1.end, apt2.end) + } + layout={Layout} + appointmentList={AppointmentListForDate} + required={true} + /> + </ContentSheet> + ); +} + +function Layout({ + sx, + className, + calendar, + appointmentList, +}: Readonly<AppointmentPickerLayoutProps>) { + const { t } = useTranslation(["officialMedicalService/appointment"]); + const isMobile = useIsMobile(); + + const givenSx = sx == null ? [] : sx instanceof Array ? sx : [sx]; + const sxProps = [ + { + margin: 0, + padding: 0, + border: 0, + "& > .MuiFormControl-root": { + width: "100%", + }, + }, + ...givenSx, + ]; + return ( + <Stack + component="fieldset" + sx={sxProps} + className={className} + aria-label={t("appointment.title")} + direction={isMobile ? "column" : "row"} + gap={4} + > + <Stack direction="column" gap={2}> + <Typography component="label"> + <Typography component="span" level="title-md"> + {t("appointment.appointmentPicker.calendarTitle")} + </Typography> + </Typography> + <Sheet + variant="soft" + sx={{ + borderRadius: "sm", + "div[role=grid]": { + width: "100%", + }, + }} + > + {calendar} + </Sheet> + <Typography + sx={{ paddingLeft: 2 }} + startDecorator={ + <Box + sx={{ + backgroundColor: "#0B6BCB", + height: "4px", + width: "10px", + }} + /> + } + > + {t("appointment.appointmentPicker.available")} + </Typography> + </Stack> + {appointmentList} + </Stack> + ); +} + +function AppointmentListForDate<T extends Appointment>({ + date, + field, + appointments, + onAppointmentSelected, + isAppointmentEqual = (apt1, apt2) => apt1 === apt2, + label, +}: Readonly<AppointmentListProps<T>>) { + const theme = useTheme(); + const labelId = useId(); + const hasAppointments = appointments.length > 0; + if (!hasAppointments || !date) { + return null; + } + + function createOnSelected(d: T) { + return () => { + onAppointmentSelected?.(d); + return field.helpers.setValue(d); + }; + } + + return ( + <Stack direction="column" gap={2}> + <Typography component="label" id={labelId}> + <Typography component="span" level="title-md"> + {label} + </Typography> + </Typography> + <RadioGroup sx={{ margin: 0 }}> + <List + aria-describedby={labelId} + orientation="horizontal" + sx={{ + padding: 0, + display: "grid", + gridTemplateColumns: "repeat(3, 1fr)", + gridAutoRows: "40px", + gap: 2, + maxWidth: "382px", + }} + > + {appointments.map((apt) => { + const isSelected = + !!field.input.value && isAppointmentEqual(field.input.value, apt); + return ( + <ListItem sx={{ padding: 0 }} key={apt.start.getTime()}> + <Chip + variant={isSelected ? "solid" : "soft"} + color={isSelected ? "primary" : "neutral"} + sx={{ + textAlign: "center", + borderRadius: "sm", + height: "100%", + minWidth: "100%", + }} + > + <Radio + disableIcon + overlay + slotProps={{ + action: { + sx: { border: "none" }, + }, + }} + value={apt.start} + color="primary" + checked={isSelected} + onChange={createOnSelected(apt)} + label={ + <Typography + component="time" + dateTime={apt.start.toTimeString().slice(0, 5)} + level="title-md" + color="primary" + sx={{ + color: isSelected ? "white" : undefined, + ".MuiListItem-root:hover &": { + color: isSelected ? "black" : undefined, + }, + fontSize: theme.fontSize.md, + fontWeight: theme.fontWeight.lg, + height: "40px", + }} + > + {timeForm.format(apt.start)} + </Typography> + } + /> + </Chip> + </ListItem> + ); + })} + </List> + </RadioGroup> + </Stack> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/ConcernStep.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/ConcernStep.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5423a82ac875b32c6ea4d774aba2f3c4c57f6fbe --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/ConcernStep.tsx @@ -0,0 +1,27 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Alert } from "@eshg/lib-portal/components/Alert"; +import { Typography } from "@mui/joy"; + +import { useTranslation } from "@/lib/i18n/client"; +import { ContentSheet } from "@/lib/shared/components/layout/contentSheet"; + +export function ConcernStep() { + const { t } = useTranslation(["officialMedicalService/appointment"]); + + return ( + <ContentSheet> + <Typography level="h2">{t("concern.title")}</Typography> + <Alert + title={t("concern.infoText.title")} + color={"primary"} + message={t("concern.infoText.description")} + /> + <Typography level="body-md">{t("concern.description")}</Typography> + <Typography level="body-md">...to be done</Typography> + </ContentSheet> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/ConfirmationSection.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/ConfirmationSection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6f0a840825de7dc421b9bb8cbebd824decb0174e --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/ConfirmationSection.tsx @@ -0,0 +1,29 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Stack, Typography } from "@mui/joy"; +import { ReactNode } from "react"; + +import { PrivacyPolicyConfirmationSection } from "@/lib/businessModules/officialMedicalService/components/appointment/steps/PrivacyPolicyConfirmationSection"; +import { useTranslation } from "@/lib/i18n/client"; + +interface ConfirmationSectionProps { + buttonBar: ReactNode; +} +export function ConfirmationSection({ + buttonBar, +}: Readonly<ConfirmationSectionProps>) { + const { t } = useTranslation(["officialMedicalService/appointment"]); + + return ( + <> + <Typography level="h2">{t("confirmation.title")}</Typography> + <Stack gap={2}> + <PrivacyPolicyConfirmationSection /> + {buttonBar} + </Stack> + </> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/DocumentAndPersonalDataStep.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/DocumentAndPersonalDataStep.tsx new file mode 100644 index 0000000000000000000000000000000000000000..281bfddedfb937de90cb5110de9064da04be6999 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/DocumentAndPersonalDataStep.tsx @@ -0,0 +1,17 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { AffectedPersonForm } from "@/lib/businessModules/officialMedicalService/components/appointment/steps/AffectedPersonForm"; +import { DocumentForm } from "@/lib/businessModules/officialMedicalService/components/appointment/steps/DocumentForm"; +import { GridColumnStack } from "@/lib/shared/components/layout/grid"; + +export function DocumentAndPersonalDataStep() { + return ( + <GridColumnStack> + <DocumentForm /> + <AffectedPersonForm name="affectedPerson" /> + </GridColumnStack> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/DocumentForm.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/DocumentForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2c9c2c08d565ba81ea486923587b3b353685072d --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/DocumentForm.tsx @@ -0,0 +1,41 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { FileType } from "@eshg/lib-portal/components/formFields/file/FileType"; + +import { FileArrayField } from "@/lib/businessModules/officialMedicalService/shared/file/FileArrayField"; +import { FormSheetTitle } from "@/lib/businessModules/travelMedicine/components/shared/components/FormSheet"; +import { useTranslation } from "@/lib/i18n/client"; +import { byBreakpoint } from "@/lib/shared/breakpoints"; +import { ContentSheet } from "@/lib/shared/components/layout/contentSheet"; + +export function DocumentForm() { + const { t } = useTranslation(["officialMedicalService/appointment"]); + + return ( + <ContentSheet sx={{ paddingX: byBreakpoint({ mobile: 0, desktop: 3 }) }}> + <FormSheetTitle requiredTitle={t("common.requiredTitle")}> + {t("documents.title")} + </FormSheetTitle> + <FileArrayField + name="files" + labels={{ + label: t("documents.fileField.title"), + placeholder: t("documents.fileField.placeholder"), + placeholderSelected: t("documents.fileField.placeholder"), + helperText: t("documents.fileField.helperText"), + inputSummary: (count: number) => + t("documents.fileField.inputSummary", { + count: count, + }), + removeAllFiles: t("documents.fileField.deleteAll"), + removeFile: t("documents.fileField.delete"), + }} + accept={[FileType.Jpeg, FileType.Png, FileType.Pdf]} + required={t("documents.fileField.required")} + /> + </ContentSheet> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/InformationCard.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/InformationCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7783b7df05feaf196563152f61f7c73ef8ca9ef6 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/InformationCard.tsx @@ -0,0 +1,62 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Alert } from "@eshg/lib-portal/components/Alert"; +import { List, ListItem, Typography } from "@mui/joy"; +import { Trans } from "react-i18next"; + +import { FormSheetTitle } from "@/lib/businessModules/travelMedicine/components/shared/components/FormSheet"; +import { useTranslation } from "@/lib/i18n/client"; +import { ContentSheet } from "@/lib/shared/components/layout/contentSheet"; + +export function InformationCard() { + const { t, i18n } = useTranslation(["officialMedicalService/appointment"]); + + return ( + <ContentSheet> + <FormSheetTitle>{t("appointmentInformation.title")}</FormSheetTitle> + <Alert + color="primary" + message={t("appointmentInformation.alertMessage")} + /> + <Typography> + <Trans + i18nKey="appointmentInformation.infoText" + ns="officialMedicalService/appointment" + i18n={i18n} + components={{ + t1: <Typography level="body-md" fontWeight="bold" />, + }} + /> + </Typography> + <Typography> + {t("appointmentInformation.requiredDocumentsHeader")} + </Typography> + <List + marker="disc" + sx={{ + "--List-gap:": "0.5px", + "--ListItem-minHeight:": 0, + "--ListItem-paddingY:": 0, + "--ListDivider-gap:": 0, + "--ListItem-paddingLeft:": 0, + fontWeight: 700, + }} + > + <ListItem>{t("appointmentInformation.listItemIdCard")}</ListItem> + <ListItem> + {t("appointmentInformation.listItemMedicalDocuments")} + </ListItem> + <ListItem> + {t("appointmentInformation.listItemCurrentMedication")} + </ListItem> + </List> + <Typography> + {t("appointmentInformation.closingGreeting")} <br /> + {t("appointmentInformation.healthDepartment")} + </Typography> + </ContentSheet> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/OverviewSection.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/OverviewSection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6bc70b632f3096a314f7f4fdc840a7ffc7cb82c9 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/OverviewSection.tsx @@ -0,0 +1,141 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useMultiStepForm } from "@eshg/lib-portal/components/form/MultiStepForm"; +import { formatDate, formatTime } from "@eshg/lib-portal/formatters/dateTime"; +import { formatPersonName } from "@eshg/lib-portal/formatters/person"; +import { formatDateToFullReadableString } from "@eshg/lib-portal/helpers/dateTime"; +import { ApiDomesticAddress } from "@eshg/official-medical-service-api"; +import { + AccessTimeOutlined, + CakeOutlined, + DateRange, + FmdGoodOutlined, + HomeOutlined, + MailOutlined, + MarkEmailReadOutlined, + PersonOutlined, +} from "@mui/icons-material"; +import { Stack, Typography } from "@mui/joy"; +import { useFormikContext } from "formik"; +import { ReactNode } from "react"; +import { isDefined } from "remeda"; + +import { AppointmentFormValues } from "@/lib/businessModules/officialMedicalService/components/appointment/AppointmentForm"; +import { useDepartmentContext } from "@/lib/businessModules/officialMedicalService/shared/contexts/DepartmentContext"; +import { DetailsField } from "@/lib/businessModules/travelMedicine/components/shared/components/DetailsField"; +import { formatDepartmentAddress } from "@/lib/businessModules/travelMedicine/helpers/appointmentFormHelper"; +import { useTranslation } from "@/lib/i18n/client"; + +export function formatStreet(address: ApiDomesticAddress) { + const { houseNumber, street } = address; + return `${street}, ${houseNumber}`; +} +export function formatCity(address: ApiDomesticAddress) { + const { city, postalCode } = address; + return `${city}, ${postalCode}`; +} + +export interface OverviewSectionProps { + buttonBar?: ReactNode; +} + +export function OverviewSection({ buttonBar }: Readonly<OverviewSectionProps>) { + const { t } = useTranslation(["officialMedicalService/appointment"]); + const { department } = useDepartmentContext(); + const { values } = useFormikContext<AppointmentFormValues>(); + const { currentStep, totalSteps } = useMultiStepForm(); + + return ( + <> + <Typography level="h2">{t("overview.title")}</Typography> + <Stack gap={2}> + <Stack gap={1}> + {/*ToDo: add concern*/} + {/*{currentStep > 1 && (*/} + {/* <>*/} + {/* </>*/} + {/*)}*/} + {currentStep > 2 && ( + <> + {currentStep === totalSteps && isDefined(department) && ( + <DetailsField + value={formatDepartmentAddress(department)} + icon={<FmdGoodOutlined />} + /> + )} + {values.appointment && ( + <DetailsField + value={formatDateToFullReadableString( + values.appointment.start, + )} + icon={<DateRange />} + /> + )} + {values.appointment && ( + <DetailsField + value={formatTime(values.appointment.start)} + icon={<AccessTimeOutlined />} + /> + )} + </> + )} + {currentStep > 3 && ( + <> + {values.affectedPerson.firstName && + values.affectedPerson.lastName && ( + <DetailsField + value={formatPersonName(values.affectedPerson)} + icon={<PersonOutlined />} + /> + )} + {values.affectedPerson.dateOfBirth && ( + <DetailsField + value={formatDate( + new Date(values.affectedPerson.dateOfBirth), + )} + icon={<CakeOutlined />} + /> + )} + {values.affectedPerson.contactAddress.street && + values.affectedPerson.contactAddress.houseNumber && + values.affectedPerson.contactAddress.houseNumber && + values.affectedPerson.contactAddress.city && ( + <Stack gap={0}> + <DetailsField + value={formatStreet( + values.affectedPerson + .contactAddress as ApiDomesticAddress, + )} + icon={<HomeOutlined />} + /> + <Typography sx={{ paddingInlineStart: "2.25rem" }}> + {formatCity( + values.affectedPerson + .contactAddress as ApiDomesticAddress, + )} + </Typography> + </Stack> + )} + {values.affectedPerson.emailAddresses && ( + <DetailsField + value={values.affectedPerson.emailAddresses} + icon={<MailOutlined />} + /> + )} + {values.confirmOnlineServices && ( + <DetailsField + value={t("overview.values.confirmOnlineServices")} + icon={<MarkEmailReadOutlined />} + /> + )} + </> + )} + </Stack> + {isDefined(buttonBar) && buttonBar} + </Stack> + </> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/PrivacyPolicyConfirmationSection.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/PrivacyPolicyConfirmationSection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9f447150f325b51acfac45ea6a483f84d0f1adce --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/PrivacyPolicyConfirmationSection.tsx @@ -0,0 +1,55 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useFileDownload } from "@eshg/lib-portal/api/files/download"; +import { ButtonLink } from "@eshg/lib-portal/components/buttons/ButtonLink"; +import { Stack } from "@mui/joy"; + +import { useCitizenPublicApi } from "@/lib/businessModules/officialMedicalService/api/clients"; +import { useTranslation } from "@/lib/i18n/client"; +import { ConfirmationCheckboxField } from "@/lib/shared/components/form/ConfirmationCheckboxField"; + +export function PrivacyPolicyConfirmationSection() { + const { t } = useTranslation(["officialMedicalService/appointment"]); + const citizenPublicApi = useCitizenPublicApi(); + + const privacyNoticeFile = useFileDownload(() => + citizenPublicApi.getPrivacyNoticeRaw(), + ); + const privacyPolicyFile = useFileDownload(() => + citizenPublicApi.getPrivacyPolicyRaw(), + ); + + return ( + <Stack gap={1}> + <ConfirmationCheckboxField + name="confirmPrivacyNotice" + label={t("confirmation.fields.confirmPrivacyNotice")} + descriptionText={ + <ButtonLink + fontSize="sm" + onClick={() => privacyNoticeFile.download()} + > + {t("confirmation.fields.privacyNotice")} + </ButtonLink> + } + required={t("confirmation.fields.confirmPrivacyNotice_required")} + /> + <ConfirmationCheckboxField + name="confirmPrivacyPolicy" + label={t("confirmation.fields.confirmPrivacyPolicy")} + descriptionText={ + <ButtonLink + fontSize="sm" + onClick={() => privacyPolicyFile.download()} + > + {t("confirmation.fields.privacyPolicy")} + </ButtonLink> + } + required={t("confirmation.fields.confirmPrivacyPolicy_required")} + /> + </Stack> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/SummaryStep.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/SummaryStep.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a9e2ec3423f70a60280f0b53dd9c942dbbaf60ed --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/components/appointment/steps/SummaryStep.tsx @@ -0,0 +1,34 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Grid } from "@mui/joy"; + +import { InformationCard } from "@/lib/businessModules/officialMedicalService/components/appointment/steps/InformationCard"; +import { OverviewSection } from "@/lib/businessModules/officialMedicalService/components/appointment/steps/OverviewSection"; +import { byBreakpoint } from "@/lib/shared/breakpoints"; +import { ContentSheet } from "@/lib/shared/components/layout/contentSheet"; +import { useIsMobile } from "@/lib/shared/hooks/useIsMobile"; + +export function SummaryStep() { + const isMobile = useIsMobile(); + + return ( + <Grid + container + spacing={2} + sx={{ flexGrow: 1 }} + direction={isMobile ? "row" : "row-reverse"} + > + <Grid {...byBreakpoint({ mobile: 12, desktop: 6 })}> + <ContentSheet> + <OverviewSection /> + </ContentSheet> + </Grid> + <Grid {...byBreakpoint({ mobile: 12, desktop: 6 })}> + <InformationCard /> + </Grid> + </Grid> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/components/landing/LandingpageContent.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/components/landing/LandingpageContent.tsx index 60e0cc36d57673e6838cd3ac6da222d400951e2b..88ebcade5a3970d66a1945bd204499277aeca179 100644 --- a/citizen-portal/src/lib/businessModules/officialMedicalService/components/landing/LandingpageContent.tsx +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/components/landing/LandingpageContent.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Typography } from "@mui/joy"; +import { styled } from "@mui/joy"; import { useSuspenseQueries } from "@tanstack/react-query"; import { useGetOpeningHoursQuery } from "@/lib/businessModules/officialMedicalService/api/queries/citizenPublicApi"; @@ -12,7 +12,10 @@ import { DepartmentInfo } from "@/lib/shared/api/models/DepartmentInfo"; import { AddressSection } from "@/lib/shared/components/AddressSection"; import { ContactSection } from "@/lib/shared/components/ContactSection"; import { OpeningHoursSection } from "@/lib/shared/components/OpeningHoursSection"; -import { InfoSectionGrid } from "@/lib/shared/components/infoSection"; +import { + InfoSectionGrid, + InfoSectionTitle, +} from "@/lib/shared/components/infoSection"; import { ContentSheet, ContentSheetTitle, @@ -23,6 +26,10 @@ interface LandingpageContentProps { departmentInfo: DepartmentInfo; } +const StyledList = styled("ul")({ + marginTop: "5px", +}); + export function LandingpageContent(props: LandingpageContentProps) { const { t } = useTranslation(["officialMedicalService/landing"]); const [{ data: openingHours }] = useSuspenseQueries({ @@ -33,7 +40,35 @@ export function LandingpageContent(props: LandingpageContentProps) { <GridColumnStack> <ContentSheet> <ContentSheetTitle>{t("information.title")}</ContentSheetTitle> - <Typography>{t("information.text")}</Typography> + <p> + <InfoSectionTitle> + {t("information.pleaseCome")} {props.departmentInfo.name},{" "} + {props.departmentInfo.street} {props.departmentInfo.houseNumber},{" "} + {props.departmentInfo.postalCode} {props.departmentInfo.city} + </InfoSectionTitle> + <InfoSectionTitle>{t("information.pleaseBring")}</InfoSectionTitle> + <StyledList> + <li>{t("information.perso")}</li> + <li>{t("information.anamnesis")}</li> + <li>{t("information.orderLetter")}</li> + <li>{t("information.medicalDocuments")}</li> + <li>{t("information.meds")}</li> + </StyledList> + <InfoSectionTitle>{t("information.forAttests")}</InfoSectionTitle> + <StyledList> + <li>{t("information.onlyFrankfurt")}</li> + <li>{t("information.comeOnDay")}</li> + </StyledList> + <InfoSectionTitle> + {t("information.definitelyBring")} + </InfoSectionTitle> + <StyledList> + <li>{t("information.docsDoctor")}</li> + <li>{t("information.docsUniversity")}</li> + <li>{t("information.studentCard")}</li> + <li>{t("information.fee")}</li> + </StyledList> + </p> </ContentSheet> <ContentSheet> <ContentSheetTitle>{t("contact.title")}</ContentSheetTitle> diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/components/landing/LandingpageSidePanel.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/components/landing/LandingpageSidePanel.tsx index e1d7fa297e7975cadfb4ef232a83345fc5ed5f4d..afb63e75d10c1dc4007790df13d93dfb7fd49a1b 100644 --- a/citizen-portal/src/lib/businessModules/officialMedicalService/components/landing/LandingpageSidePanel.tsx +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/components/landing/LandingpageSidePanel.tsx @@ -4,7 +4,9 @@ */ import { Button, Stack, Typography } from "@mui/joy"; +import { useRouter } from "next/navigation"; +import { useCitizenRoutes } from "@/lib/businessModules/officialMedicalService/shared/routes"; import { useTranslation } from "@/lib/i18n/client"; import { ContentSheet, @@ -13,12 +15,26 @@ import { export function LandingpageSidePanel() { const { t } = useTranslation(["officialMedicalService/landing"]); + const router = useRouter(); + const citizenRoutes = useCitizenRoutes(); + + function handleBookAppointment() { + router.push(citizenRoutes.appointment); + } return ( <ContentSheet> <ContentSheetTitle>{t("personalArea.title")}</ContentSheetTitle> <Typography>{t("personalArea.information")}</Typography> <Stack direction="column" gap={2}> + <Button + type="submit" + onClick={() => { + handleBookAppointment(); + }} + > + {t("personalArea.bookAppointment")} + </Button> <Button type="submit" variant="outlined"> {t("personalArea.goToPersonalArea")} </Button> diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/locales/de/appointment.json b/citizen-portal/src/lib/businessModules/officialMedicalService/locales/de/appointment.json new file mode 100644 index 0000000000000000000000000000000000000000..c5438c9d9659f5b5e19e199e4edd29d4c652ad68 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/locales/de/appointment.json @@ -0,0 +1,125 @@ +{ + "common": { + "title": "Amtsärztlicher Dienst", + "requiredTitle": "*Pflichtfeld", + "stepTitle": "Schritt {{currentStepIndex}} von {{totalSteps}}", + "snackbar": { + "success": "Anfrage für Ihr Anliegen wurde erfolgreich gesendet" + } + }, + "concern": { + "title": "Anliegen auswählen", + "description": "Ihre Terminart finden Sie in dem zugesendeten Auftragsschreiben.", + "infoText": { + "title": "Schulbezogene Anliegen", + "description": "Kommen Sie bitte bei Prüfungen spätestens am Prüfungstag (auch ohne Termin) zu uns oder bei Abgabe einer wissenschaftlichen Arbeit nach vorheriger telefonischer Terminvereinbarung" + } + }, + "appointment": { + "title": "Verfügbare Termine", + "fields": { + "error": { + "title": "Fehler beim Laden der Termine", + "description": "Bitte laden Sie die Seite neu.", + "reload": "Seite neu laden" + } + }, + "appointmentPicker": { + "noAppointmentForSelectedDate": "Für den ausgewählten Tag stehen keine freien Termine zur Verfügung", + "noAppointmentSelected": "Es wurde kein Termin ausgewählt.", + "noAppointmentsAvailable": "Derzeit sind keine Termine verfügbar", + "tryLater": "Wir schalten in kürze weitere Termine frei. Bitte versuchen Sie es zu einem späteren Zeitpunkt erneut.", + "calendarTitle": "Datum", + "available": "verfügbar", + "requiredAppointment": "Bitte einen Termin auswählen", + "requiredDay": "Bitte einen Tag auswählen", + "monthSelection": "Termin Kalendermonat", + "nextMonth": "zum nächsten Monat", + "prevMonth": "zum vorherigen Monat", + "listLabel": "Verfügbare Uhrzeiten" + }, + "backToOverview": "Zurück" + }, + "documents": { + "title": "Erforderliche Unterlagen", + "fileField": { + "title": "Auftragsschreiben", + "required": "Bitte Auftragsschreiben hochladen", + "helperText": "Datei als PDF, PNG oder JPG hochladen", + "inputSummary_one": "{{count}} Datei hochgeladen", + "inputSummary_other": "{{count}} Dateien hochgeladen", + "placeholder": "Datei auswählen", + "file": "Datei", + "size": "Größe", + "format": "Format", + "delete": "Entfernen", + "deleteAll": "Alles entfernen", + "error": "Die Datei konnte nicht hochgeladen werden. Bitte versuchen Sie es erneut." + } + }, + "affectedPerson": { + "title": "Persönliche Daten", + "fields": { + "salutation": "Anrede", + "title": "Titel", + "firstName": "Vorname", + "firstName_required": "Pflichtfeld ausfüllen.", + "lastName": "Nachname", + "lastName_required": "Pflichtfeld ausfüllen.", + "dateOfBirth": "Geburtsdatum", + "dateOfBirth_required": "Pflichtfeld ausfüllen.", + "contactAddress": { + "street": "Straße", + "street_required": "Pflichtfeld ausfüllen.", + "houseNumber": "Hausnummer", + "houseNumber_required": "Pflichtfeld ausfüllen.", + "addressAddition": "Addresszusatz", + "postalCode": "Postleitzahl", + "postalCode_required": "Pflichtfeld ausfüllen.", + "city": "Ort", + "city_required": "Pflichtfeld ausfüllen." + }, + "phoneNumbers": "Telefon", + "emailAddresses": "E-Mail-Adresse", + "emailAddresses_required": "Pflichtfeld ausfüllen.", + "confirmOnlineServices": "Ich bestätige, dass ich die Online-Dienste nutzen möchte und die hierzu notwendigen E-Mails erhalten möchte.", + "confirmOnlineServices_required": "Bitte bestätigen." + } + }, + "overview": { + "title": "Übersicht", + "goForward": "Weiter", + "goBack": "Zurück", + "cancel": "Abbrechen", + "values": { + "confirmOnlineServices": "Bestätigungsmail senden", + "appointmentDuration": "(ca. {{ durationInMinutes }} Minuten)" + } + }, + "appointmentInformation": { + "title": "Informationen zum Termin", + "alertMessage": "Bitte kommen Sie am Untersuchungstag nüchtern in die Breite Gasse 28, 60313 Frankfurt am Main (Zimmer 3.04).", + "infoText": "Wir prüfen Ihre Anfrage. Nach erfolgreicher Prüfung erhalten Sie eine <t1> Terminbestätigung </t1> per E-Mail. Dort sind alle Informationen zum Termin enthalten. Sie haben zudem die Möglichkeit den Termin zu ändern oder zu stornieren.", + "requiredDocumentsHeader": "Die notwendigen Dokumente, die Sie bitte zum Termin mitbringen sollten, sind:", + "listItemIdCard": "Personalausweis / Reisepass ", + "listItemMedicalDocuments": "ärztliche Unterlagen in Kopie (z.B. Krankenhausentlassungsberichte, Atteste, Bescheinigungen etc.)", + "listItemCurrentMedication": "einen Nachweis der zurzeit verordneten Medikamente (falls vorhanden)", + "closingGreeting": "Mit freundlichen Grüßen", + "healthDepartment": "Ihr Gesundheitsamt" + }, + "confirmation": { + "title": "Anliegen amtsärztliches Gutachten", + "submit": "Anliegen senden", + "goBack": "Zurück", + "cancel": "Abbrechen", + "success": "Anfrage für Ihr Anliegen wurde erfolgreich gesendet", + "fields": { + "confirmPrivacyNotice": "Ich akzeptiere den Datenschutzhinweis.", + "privacyNotice": "Zum Datenschutzhinweis", + "confirmPrivacyNotice_required": "Bitte Zustimmung erteilen um fortzufahren.", + "confirmPrivacyPolicy": "Ich akzeptiere die Datenschutzerklärung.", + "privacyPolicy": "Zur Datenschutzerklärung", + "confirmPrivacyPolicy_required": "Bitte Zustimmung erteilen um fortzufahren." + } + } +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/locales/de/landing.json b/citizen-portal/src/lib/businessModules/officialMedicalService/locales/de/landing.json index 656f405f159845aea4c7390c655178a13c9877ba..1593a8dfd38e229034ce75de75f1f9ead1766dce 100644 --- a/citizen-portal/src/lib/businessModules/officialMedicalService/locales/de/landing.json +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/locales/de/landing.json @@ -2,7 +2,21 @@ "pageTitle": "Amtsärztlicher Dienst", "information": { "title": "Informationen", - "text": "Infotext zum Amtsärztlichen Dienst" + "pleaseCome": "Bitte kommen Sie am Untersuchungstag nüchtern zu uns:", + "pleaseBring": "Bringen Sie folgende Unterlagen zur amtsärztlichen Untersuchung mit:", + "perso": "Personalausweis / Reisepass", + "anamnesis": "Ausgefüllter Anamnesebogen (siehe Downloadbox)", + "orderLetter": "Auftragsschreiben (Schreiben des Dienstherrn mit Angabe des Untersuchungsgrundes) in Kopie", + "medicalDocuments": "ärztliche Unterlagen in Kopie (z.B. Krankenhausentlassungsberichte, Atteste, Bescheinigungen etc.)", + "meds": "einen Nachweis der zurzeit verordneten Medikamente (falls vorhanden)", + "forAttests": "Für Atteste zur Prüfungsfähigkeit ist Folgendes zu beachten und mitzubringen", + "onlyFrankfurt": "Wir sind nur für in Frankfurt Studierende zuständig", + "comeOnDay": "Kommen Sie bitte bei Prüfungen spätestens am Prüfungstag (auch ohne Termin) zu uns oder bei Abgabe einer wissenschaftlichen Arbeit nach vorheriger telefonischer Terminvereinbarung", + "definitelyBring": "Zum Termin unbedingt mitzubringen sind", + "docsDoctor": "Ihre ärztlichen Unterlagen mit Diagnose von Ihrem niedergelassenen Arzt.", + "docsUniversity": "Bescheinigung der Universität oder Hochschule über den Prüfungstermin.", + "studentCard": "Studentenausweis + Personalausweis oder Reisepass.", + "fee": "Gebühr in Höhe von 50 Euro (zahlbar bar oder mit EC-Karte)." }, "contact": { "title": "Kontakt und Erreichbarkeit", @@ -19,8 +33,9 @@ } }, "personalArea": { - "title": "Vorgang", - "information": "Infotext zum Vorgang", - "goToPersonalArea": "Vorgang einsehen" + "title": "Hier können Sie Ihr Anliegen für ein amtsärztliches Gutachten melden", + "information": "Um ein Anliegen zu melden ist ein Auftragsschreiben notwendig.", + "bookAppointment": "Anliegen melden", + "goToPersonalArea": "Zu meinem Anliegen" } } diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/locales/en/appointment.json b/citizen-portal/src/lib/businessModules/officialMedicalService/locales/en/appointment.json new file mode 100644 index 0000000000000000000000000000000000000000..51e4aa5f9a5874be6b0ce487546df3ad7a99c212 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/locales/en/appointment.json @@ -0,0 +1,125 @@ +{ + "common": { + "title": "Amtsärztlicher Dienst", + "requiredTitle": "*Required", + "stepTitle": "Step {{currentStepIndex}} of {{totalSteps}}", + "snackbar": { + "success": "The request for your concern has been sent successfully" + } + }, + "concern": { + "title": "Select concern", + "description": "The appointment type will be included in the order letter sent to you.", + "infoText": { + "title": "School-related concern", + "description": "For exams, please come to us at the latest on the day of the exam (even without an appointment) or if you are submitting an academic paper, please make an appointment by telephone in advance" + } + }, + "appointment": { + "title": "Available appointments", + "fields": { + "error": { + "title": "Error loading appointments", + "description": "Please reload the page.", + "reload": "Reload page" + } + }, + "appointmentPicker": { + "noAppointmentForSelectedDate": "There are no free appointments for the selected day.", + "noAppointmentSelected": "Appointment has not been selected.", + "noAppointmentsAvailable": "There are currently no available appointments", + "tryLater": "We will make more appointments available soon. Please try again later.", + "calendarTitle": "Date", + "available": "available", + "requiredAppointment": "Please select an appointment", + "requiredDay": "Please select a day", + "monthSelection": "Appointment calendar week", + "nextMonth": "next month", + "prevMonth": "previous month", + "listLabel": "Available times" + }, + "backToOverview": "Back" + }, + "documents": { + "title": "Required documents", + "fileField": { + "title": "Offer letter", + "required": "Please upload your offer letter", + "helperText": "Upload file as PDF, PNG or JPG", + "inputSummary_one": "{{count}} file uploaded", + "inputSummary_other": "{{count}} files uploaded", + "placeholder": "Select file", + "file": "File", + "size": "Size", + "format": "Format", + "delete": "Delete", + "deleteAll": "Delete all", + "error": "File could not be uploaded. Please try again" + } + }, + "affectedPerson": { + "title": "Personal data", + "fields": { + "salutation": "Salutation", + "title": "Title", + "firstName": "First name", + "firstName_required": "Required", + "lastName": "Last name", + "lastName_required": "Required", + "dateOfBirth": "Date of birth", + "dateOfBirth_required": "Required", + "contactAddress": { + "street": "Street", + "street_required": "Required", + "houseNumber": "House number", + "houseNumber_required": "Required", + "addressAddition": "Apartment, unit, suite etc", + "postalCode": "Postal Code", + "postalCode_required": "Required", + "city": "City", + "city_required": "Required" + }, + "phoneNumbers": "Phone", + "emailAddresses": "E-Mail Addresses", + "emailAddresses_required": "Required", + "confirmOnlineServices": "I confirm that I would like to use the online services and receive the necessary emails.", + "confirmOnlineServices_required": "Please confirm." + } + }, + "overview": { + "title": "Overview", + "goForward": "Continue", + "goBack": "Back", + "cancel": "Cancel", + "values": { + "confirmOnlineServices": "Send confirmation email", + "appointmentDuration": "(ca. {{ durationInMinutes }} Minutes)" + } + }, + "appointmentInformation": { + "title": "Appointment information", + "alertMessage": "Please come to Breite Gasse 28, 60313 Frankfurt am Main (room 3.04) sober on the day of the examination.", + "infoText": "We will review your request. After successful verification, you will receive a <t1> appointment confirmation </t1> by email. All information about the appointment can be found there. You also have the option to change or cancel the appointment.", + "requiredDocumentsHeader": "The necessary documents that you should bring with you to the appointment are:", + "listItemIdCard": "ID / Passport", + "listItemMedicalDocuments": "Copies of medical documents (e.g. hospital discharge reports, certificates, etc.)", + "listItemCurrentMedication": "proof of the currently prescribed medication (if any)", + "closingGreeting": "Sincerely", + "healthDepartment": "Your Health Department" + }, + "confirmation": { + "title": "Request an official medical report", + "submit": "Submit concern", + "goBack": "Back", + "cancel": "Cancel", + "success": "Your request has been sent successfully", + "fields": { + "confirmPrivacyNotice": "I accept the data protection notice.", + "privacyNotice": "To the data protection notice", + "confirmPrivacyNotice_required": "Please give consent to continue.", + "confirmPrivacyPolicy": "I accept the privacy policy.", + "privacyPolicy": "To the privacy policy", + "confirmPrivacyPolicy_required": "Please give consent to continue." + } + } +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/locales/en/landing.json b/citizen-portal/src/lib/businessModules/officialMedicalService/locales/en/landing.json index 6585e6089e9378b66367841e02a5cc155224db50..f4f8a983794af388b4dd74b4d1ccdac421b30ade 100644 --- a/citizen-portal/src/lib/businessModules/officialMedicalService/locales/en/landing.json +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/locales/en/landing.json @@ -1,8 +1,22 @@ { "pageTitle": "Official Medical Service", "information": { - "title": "Information", - "text": "Much Information Very Official Medical Service" + "title": "Informationen", + "pleaseCome": "Bitte kommen Sie am Untersuchungstag nüchtern zu uns:", + "pleaseBring": "Bringen Sie folgende Unterlagen zur amtsärztlichen Untersuchung mit:", + "perso": "Personalausweis / Reisepass", + "anamnesis": "Ausgefüllter Anamnesebogen (siehe Downloadbox)", + "orderLetter": "Auftragsschreiben (Schreiben des Dienstherrn mit Angabe des Untersuchungsgrundes) in Kopie", + "medicalDocuments": "ärztliche Unterlagen in Kopie (z.B. Krankenhausentlassungsberichte, Atteste, Bescheinigungen etc.)", + "meds": "einen Nachweis der zurzeit verordneten Medikamente (falls vorhanden)", + "forAttests": "Für Atteste zur Prüfungsfähigkeit ist Folgendes zu beachten und mitzubringen", + "onlyFrankfurt": "Wir sind nur für in Frankfurt Studierende zuständig", + "comeOnDay": "Kommen Sie bitte bei Prüfungen spätestens am Prüfungstag (auch ohne Termin) zu uns oder bei Abgabe einer wissenschaftlichen Arbeit nach vorheriger telefonischer Terminvereinbarung", + "definitelyBring": "Zum Termin unbedingt mitzubringen sind", + "docsDoctor": "Ihre ärztlichen Unterlagen mit Diagnose von Ihrem niedergelassenen Arzt.", + "docsUniversity": "Bescheinigung der Universität oder Hochschule über den Prüfungstermin.", + "studentCard": "Studentenausweis + Personalausweis oder Reisepass.", + "fee": "Gebühr in Höhe von 50 Euro (zahlbar bar oder mit EC-Karte)." }, "contact": { "title": "Contact and Availability", @@ -19,8 +33,9 @@ } }, "personalArea": { - "title": "Process", - "information": "All the Information about the Process", + "title": "Here you can report your concern for an official medical examination.", + "information": "To report a concern, a confirmation letter is needed", + "bookAppointment": "Report Concern", "goToPersonalArea": "View Process" } } diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/shared/MultiStepFormButtonBar.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/shared/MultiStepFormButtonBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fd7c2eea48a66878c5bb0b0c892f25638c6c4656 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/shared/MultiStepFormButtonBar.tsx @@ -0,0 +1,63 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useMultiStepForm } from "@eshg/lib-portal/components/form/MultiStepForm"; +import { InternalLinkButton } from "@eshg/lib-portal/components/navigation/InternalLinkButton"; +import { Button, Stack } from "@mui/joy"; +import { useFormikContext } from "formik"; +import { isEmpty } from "remeda"; + +interface MultiStepFormButtonBarProps { + href: string; + submitLabel?: string; + cancelLabel: string; + forwardLabel: string; + backLabel: string; +} + +export function MultiStepFormButtonBar({ + href, + submitLabel, + cancelLabel, + forwardLabel, + backLabel, +}: Readonly<MultiStepFormButtonBarProps>) { + const { currentStep, totalSteps, goForward, goBack } = useMultiStepForm(); + + const { handleSubmit, validateForm, setTouched, touched } = + useFormikContext(); + + async function handleValidation(handleFunction: () => void) { + const errors = await validateForm(); + await setTouched({ ...touched, ...errors }); + + if (isEmpty(errors)) { + handleFunction(); + } + } + + return ( + <Stack gap={2}> + {currentStep < totalSteps && ( + <Button onClick={() => handleValidation(goForward)}> + {forwardLabel} + </Button> + )} + {submitLabel && currentStep === totalSteps && ( + <Button onClick={() => handleValidation(handleSubmit)}> + {submitLabel} + </Button> + )} + {currentStep > 1 && ( + <Button variant="outlined" onClick={goBack}> + {backLabel} + </Button> + )} + <InternalLinkButton variant="soft" color="neutral" href={href}> + {cancelLabel} + </InternalLinkButton> + </Stack> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/shared/contexts/DepartmentContext.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/shared/contexts/DepartmentContext.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c276e67ff2750b87f936288ede137b38c3267f54 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/shared/contexts/DepartmentContext.tsx @@ -0,0 +1,58 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ApiGetDepartmentInfoResponse } from "@eshg/base-api"; +import { RequiresChildren } from "@eshg/lib-portal/types/react"; +import { useSuspenseQueries } from "@tanstack/react-query"; +import { + Dispatch, + SetStateAction, + createContext, + useContext, + useMemo, + useState, +} from "react"; + +import { useGetDepartmentInfoQuery } from "@/lib/businessModules/officialMedicalService/api/queries/citizenPublicApi"; + +interface DepartmentContextProps { + department?: ApiGetDepartmentInfoResponse; + setDepartment: Dispatch<SetStateAction<ApiGetDepartmentInfoResponse>>; +} + +export const DepartmentContext = createContext<DepartmentContextProps | null>( + null, +); + +type DepartmentContextProviderProps = RequiresChildren; + +export function DepartmentContextProvider( + props: Readonly<DepartmentContextProviderProps>, +) { + const [{ data: departmentInfo }] = useSuspenseQueries({ + queries: [useGetDepartmentInfoQuery()], + }); + + const [department, setDepartment] = + useState<ApiGetDepartmentInfoResponse>(departmentInfo); + + const value = useMemo(() => ({ department, setDepartment }), [department]); + + return ( + <DepartmentContext.Provider value={value}> + {props.children} + </DepartmentContext.Provider> + ); +} + +export function useDepartmentContext() { + const context = useContext(DepartmentContext); + if (!context) { + throw new Error( + "useDepartmentContext must be used with a DepartmentProvider", + ); + } + return context; +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/shared/file/FileArrayField.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/shared/file/FileArrayField.tsx new file mode 100644 index 0000000000000000000000000000000000000000..da5ed3540fe5d9e43b96217f71fdf96be3a21fa6 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/shared/file/FileArrayField.tsx @@ -0,0 +1,280 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useBaseField } from "@eshg/lib-portal/components/formFields/BaseField"; +import { FileType } from "@eshg/lib-portal/components/formFields/file/FileType"; +import { validateFileType } from "@eshg/lib-portal/components/formFields/file/validators"; +import { isNonEmptyArray } from "@eshg/lib-portal/helpers/guards"; +import { FieldProps } from "@eshg/lib-portal/types/form"; +import { CheckOutlined, CloseOutlined } from "@mui/icons-material"; +import { + Box, + FormControl, + FormHelperText, + FormLabel, + FormLabelProps, + Sheet, + Stack, + Typography, + styled, +} from "@mui/joy"; +import { ChangeEvent, PropsWithChildren, useId, useRef } from "react"; +import { isDefined, isFunction, isString } from "remeda"; + +import { theme } from "@/lib/baseModule/theme/theme"; +import { FileSheet } from "@/lib/businessModules/officialMedicalService/shared/file/FileSheet"; +import { useDragAndDropMultiple } from "@/lib/businessModules/officialMedicalService/shared/file/useDragAndDropMultiple"; +import { useTranslation } from "@/lib/i18n/client"; +import { byBreakpoint } from "@/lib/shared/breakpoints"; +import { + FileButton, + StyledRemoveButton, +} from "@/lib/shared/components/form/file/buttonVariants"; + +const HiddenInput = styled("input")({ display: "none" }); + +function resolveAcceptedFileTypes( + accept: FileType | FileType[] | undefined, +): FileType[] { + if (accept === undefined) { + return []; + } + if (Array.isArray(accept)) { + return accept; + } + return [accept]; +} + +function renderLabel(label: string, labelProps: FileLabelProps) { + return ( + <FormLabel {...labelProps}> + <Typography sx={{ fontWeight: "bold" }}>{label}</Typography> + </FormLabel> + ); +} + +export interface FileArrayFieldProps + extends Omit<FieldProps<File[] | null>, "label" | "validate"> { + accept?: FileType | FileType[]; + labels: FileArrayFieldLabels; + onChange?: (files: File[] | null) => void; +} + +export interface FileArrayFieldLabels { + label: string; + placeholder: string; + placeholderSelected: string; + helperText: string; + inputSummary: (count: number) => string; + removeAllFiles: string; + removeFile: string; +} + +type FileLabelProps = Pick<FormLabelProps, "htmlFor">; + +export function FileArrayField({ + labels, + ...props +}: Readonly<FileArrayFieldProps>) { + const { i18n } = useTranslation(); + const acceptedFileTypes = resolveAcceptedFileTypes(props.accept); + const fileTypeErrorVal = validateFileType( + acceptedFileTypes, + i18n.resolvedLanguage ?? "de-DE", + ); + const field = useBaseField<File[] | null>({ + ...props, + }); + const fileInputRef = useRef<HTMLInputElement>(null); + const fileInputId = useId(); + const acceptedMimeTypes = + acceptedFileTypes.length > 0 + ? acceptedFileTypes + .flatMap((fileType) => + isString(fileType.mimeType) + ? [fileType.mimeType] + : fileType.mimeType, + ) + .join(", ") + : undefined; + + async function handleChange(event: ChangeEvent<HTMLInputElement>) { + if (event.target.files !== null) { + const newArray = Array.isArray(field.input.value) + ? [...field.input.value] + : []; + const inputArray = [...event.target.files]; + inputArray.forEach((file) => { + const error = fileTypeErrorVal(file); + if (error) { + return; + } else newArray.push(file); + }); + await field.helpers.setValue([...newArray]); + await field.helpers.setTouched(true); + if (isFunction(props.onChange)) { + props.onChange(newArray); + } + } + } + + function handleButtonClick() { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + } + + const { dropState, handleFileDrag, handleFileDrop, handleFileDragLeave } = + useDragAndDropMultiple({ + validateType: fileTypeErrorVal, + onChange: async (files) => { + const newArray = Array.isArray(field.input.value) + ? [...field.input.value] + : []; + await field.helpers.setValue([...newArray, ...files]); + }, + }); + + return ( + <FormControl error={field.error} required={field.required}> + <Sheet + variant="soft" + sx={{ + borderRadius: byBreakpoint({ + mobile: theme.radius.xs, + desktop: theme.radius.md, + }), + paddingX: byBreakpoint({ mobile: 0, desktop: 3 }), + }} + > + <Stack direction="column" gap={2}> + <ResponsiveGrid> + {isNonEmptyArray(field.input.value) ? ( + <CheckOutlined + color="success" + sx={{ gridArea: "indicatorIcon" }} + /> + ) : ( + <CloseOutlined + color="danger" + sx={{ gridArea: "indicatorIcon" }} + /> + )} + <Box sx={{ gridArea: "label" }}> + {renderLabel(labels.label, { htmlFor: fileInputId })} + {props.accept && + field.input.value !== null && + field.input.value.length === 0 && ( + <Typography>{labels.helperText}</Typography> + )} + {isNonEmptyArray(field.input.value) && ( + <Typography> + {labels.inputSummary(field.input.value.length)} + </Typography> + )} + </Box> + <Box + sx={{ + gridArea: "uploadButton", + justifySelf: "end", + width: byBreakpoint({ + mobile: "100%", + desktop: "80%", + }), + }} + > + <FileButton + activeDragOver={dropState === "copy"} + error={field.error || dropState === "no-drop"} + onClick={handleButtonClick} + aria-controls={fileInputId} + onDragOver={handleFileDrag} + onDrop={handleFileDrop} + onDragLeave={handleFileDragLeave} + sx={{ backgroundColor: "white", minWidth: "100%" }} + > + {isNonEmptyArray(field.input.value) + ? labels.placeholderSelected + : labels.placeholder} + </FileButton> + <HiddenInput + ref={fileInputRef} + id={fileInputId} + type="file" + name={props.name} + placeholder={labels.placeholder} + accept={acceptedMimeTypes} + required={field.required} + onChange={handleChange} + tabIndex={-1} + multiple + /> + </Box> + </ResponsiveGrid> + {isNonEmptyArray(field.input.value) && ( + <Stack direction="column" gap={2} sx={{ width: "100%" }}> + {field.input.value.map((file, index) => ( + <FileSheet + key={`${file.name}.${index}`} + file={file} + removeLabel={`${labels.removeFile}.${index}`} + acceptedFileTypes={acceptedFileTypes} + onDelete={async () => { + if (field.input.value !== null) { + await field.helpers.setValue( + field.input.value.filter((item) => item !== file), + ); + } + }} + /> + ))} + <StyledRemoveButton + onClick={async () => { + fileInputRef.current!.value = ""; + await field.helpers.setValue([]); + }} + sx={{ + alignSelf: "end", + fontSize: theme.fontSize.md, + fontWeight: theme.fontWeight.md, + paddingX: byBreakpoint({ mobile: 2, desktop: 0 }), + }} + > + {labels.removeAllFiles} + </StyledRemoveButton> + </Stack> + )} + </Stack> + </Sheet> + {isDefined(field.helperText) && ( + <FormHelperText id={`${fileInputId}-helper-text`}> + {field.helperText} + </FormHelperText> + )} + </FormControl> + ); +} + +function ResponsiveGrid({ children }: Readonly<PropsWithChildren>) { + return ( + <Box + sx={{ + display: "grid", + gap: 2, + gridTemplateColumns: byBreakpoint({ + mobile: "max-content 1fr", + desktop: "max-content 1fr 1fr", + }), + gridTemplateAreas: byBreakpoint({ + mobile: '"indicatorIcon label" "uploadButton uploadButton"', + desktop: '"indicatorIcon label uploadButton"', + }), + paddingX: byBreakpoint({ mobile: 2, desktop: 0 }), + }} + > + {children} + </Box> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/shared/file/FileSheet.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/shared/file/FileSheet.tsx new file mode 100644 index 0000000000000000000000000000000000000000..388e68f37e471d73dc3f43f302cc007288750766 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/shared/file/FileSheet.tsx @@ -0,0 +1,106 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { FileType } from "@eshg/lib-portal/components/formFields/file/FileType"; +import { FileLike } from "@eshg/lib-portal/components/formFields/file/validators"; +import { formatFileSize } from "@eshg/lib-portal/helpers/file"; +import { DeleteOutlined } from "@mui/icons-material"; +import { Box, IconButton, Sheet, Typography } from "@mui/joy"; +import { PropsWithChildren } from "react"; + +import { theme } from "@/lib/baseModule/theme/theme"; +import { byBreakpoint } from "@/lib/shared/breakpoints"; + +export interface FileSheet { + file: File; + acceptedFileTypes: FileType[]; + removeLabel?: string; + onDelete?: () => Promise<void>; +} +export function FileSheet({ + file, + acceptedFileTypes, + onDelete, + removeLabel, +}: Readonly<FileSheet>) { + return ( + <Sheet + key={`${file.name}+${file.size}`} + sx={{ + borderRadius: byBreakpoint({ + mobile: theme.radius.xs, + desktop: theme.radius.md, + }), + padding: 2, + }} + > + <ResponsiveGrid> + <Typography sx={{ gridArea: "fileName", wordBreak: "break-all" }}> + {file.name} + </Typography> + <Typography sx={{ gridArea: "fileFormat", justifySelf: "end" }}> + {formatFileType(acceptedFileTypes, file)} + </Typography> + <Typography + sx={{ + gridArea: "fileSize", + justifySelf: byBreakpoint({ + mobile: "start", + desktop: "end", + }), + }} + > + {formatFileSize(file.size)} + </Typography> + {onDelete && ( + <IconButton + aria-label={removeLabel} + color="danger" + onClick={onDelete} + sx={{ + minHeight: "24px", + minWidth: "24px", + paddingX: 0, + gridArea: "deleteButton", + alignSelf: "start", + }} + > + <DeleteOutlined /> + </IconButton> + )} + </ResponsiveGrid> + </Sheet> + ); +} + +function formatFileType(acceptedFileType: FileType[], file: FileLike) { + return acceptedFileType.map((fileType) => { + if (file.type === fileType.mimeType) { + return fileType.name; + } + }); +} + +function ResponsiveGrid({ children }: Readonly<PropsWithChildren>) { + return ( + <Box + sx={{ + display: "grid", + rowGap: 0.5, + columnGap: 2, + gridTemplateColumns: byBreakpoint({ + mobile: "65% 1fr max-content", + desktop: "70% 1fr 1fr max-content", + }), + gridTemplateAreas: byBreakpoint({ + mobile: '"fileName fileFormat deleteButton" "fileSize . ."', + desktop: '"fileName fileFormat fileSize deleteButton"', + }), + }} + > + {children} + </Box> + ); +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/shared/file/useDragAndDropMultiple.tsx b/citizen-portal/src/lib/businessModules/officialMedicalService/shared/file/useDragAndDropMultiple.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3431aee5d35dbec4f373cf2c69574ca1fbff0e55 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/shared/file/useDragAndDropMultiple.tsx @@ -0,0 +1,73 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { FileLike } from "@eshg/lib-portal/components/formFields/file/validators"; +import { DragEvent, useCallback, useState } from "react"; + +export function useDragAndDropMultiple({ + onChange, + validateType, +}: { + validateType: (f: FileLike | null) => string | undefined; + onChange: (f: File[]) => unknown; +}) { + const [dropState, setDropState] = useState<"copy" | "no-drop" | undefined>(); + const handleFileDrop = useCallback( + (ev: DragEvent<HTMLButtonElement>) => { + ev.preventDefault(); + setDropState(undefined); + if (ev.dataTransfer.items) { + const files: File[] = []; + // Use DataTransferItemList interface to access the file(s) + [...ev.dataTransfer.items].forEach((item) => { + // If dropped items aren't files, reject them + if (item.kind === "file") { + const file = item.getAsFile(); + const error = validateType(file); + if (error) { + return; + } else { + return files.push(file!); + } + } + }); + onChange(files); + } + }, + [setDropState, onChange, validateType], + ); + + const handleFileDrag = useCallback( + (ev: DragEvent<HTMLButtonElement>) => { + ev.preventDefault(); + const errors: string[] = []; + [...ev.dataTransfer.items]?.map((item) => { + const error = validateType(item); + if (error !== undefined) errors.push(error); + }); + if (ev.dataTransfer.items === null || errors.length > 0) { + setDropState("no-drop"); + ev.dataTransfer.dropEffect = "none"; + ev.dataTransfer.effectAllowed = "none"; + return; + } + setDropState("copy"); + ev.dataTransfer.dropEffect = "copy"; + ev.dataTransfer.effectAllowed = "copy"; + }, + [setDropState, validateType], + ); + + const handleFileDragLeave = useCallback(() => { + setDropState(undefined); + }, [setDropState]); + + return { + handleFileDrop, + handleFileDrag, + handleFileDragLeave, + dropState, + }; +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/shared/helpers.ts b/citizen-portal/src/lib/businessModules/officialMedicalService/shared/helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..7434229eb1f22ff5d51b10c7b71c2e4035851e71 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/shared/helpers.ts @@ -0,0 +1,67 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { durationBetweenDatesInMinutes } from "@eshg/lib-portal/helpers/dateTime"; +import { mapOptionalValue } from "@eshg/lib-portal/helpers/form"; +import { PostCitizenProcedureRequest } from "@eshg/official-medical-service-api"; +import { isDefined, isEmpty } from "remeda"; + +import { AppointmentFormValues } from "@/lib/businessModules/officialMedicalService/components/appointment/AppointmentForm"; + +export function mapToPostCitizenProcedureRequest( + values: AppointmentFormValues, +): PostCitizenProcedureRequest { + return { + files: values.files as Blob[], + request: { + affectedPerson: { + salutation: mapOptionalValue(values.affectedPerson.salutation), + title: mapOptionalValue(values.affectedPerson.title), + firstName: values.affectedPerson.firstName, + lastName: values.affectedPerson.lastName, + dateOfBirth: new Date(values.affectedPerson.dateOfBirth), + contactAddress: { + type: "DomesticAddress", + street: values.affectedPerson.contactAddress.street, + houseNumber: values.affectedPerson.contactAddress.houseNumber, + addressAddition: mapOptionalValue( + values.affectedPerson.contactAddress.addressAddition?.trim(), + ), + postalCode: values.affectedPerson.contactAddress.postalCode, + city: values.affectedPerson.contactAddress.city, + country: "DE", + }, + emailAddresses: [values.affectedPerson.emailAddresses], + phoneNumbers: !isEmpty(values.affectedPerson.phoneNumbers) + ? [values.affectedPerson.phoneNumbers?.trim()] + : undefined, + version: 0, + }, + appointment: { + appointmentType: "OFFICIAL_MEDICAL_SERVICE_SHORT", // ToDo: change in upcoming ticket + bookingInfo: { + bookingType: "APPOINTMENT_BLOCK", + duration: isDefined(values.appointment) + ? durationBetweenDatesInMinutes( + values.appointment.start, + values.appointment.end, + ) + : 0, + start: values.appointment!.start, + }, + }, + // ToDo: change in upcoming ticket + concern: { + categoryNameDe: "categoryNameDe", + categoryNameEn: "categoryNameEn", + highPriority: true, + nameDe: "nameDe", + nameEn: "nameEn", + version: 0, + visibleInOnlinePortal: true, + }, + }, + }; +} diff --git a/citizen-portal/src/lib/businessModules/officialMedicalService/shared/routes.ts b/citizen-portal/src/lib/businessModules/officialMedicalService/shared/routes.ts index 6d5db129b4c25f8260a0ae9c0b7a344596198986..dc8b2883b080eeea3eca442a7d372292b25fe940 100644 --- a/citizen-portal/src/lib/businessModules/officialMedicalService/shared/routes.ts +++ b/citizen-portal/src/lib/businessModules/officialMedicalService/shared/routes.ts @@ -11,9 +11,10 @@ import { useGivenLang } from "@/lib/i18n/useLang"; export function citizenRoutes(locale: SupportedLanguage | undefined) { return defineRoutes( - `${baseRoutes(locale).citizenPath.index}/amtsaerztlicherdienst `, + `${baseRoutes(locale).citizenPath.index}/amtsaerztlicherdienst`, (officialMedicalServicePath) => ({ overview: officialMedicalServicePath("/"), + appointment: officialMedicalServicePath("/termin"), }), ); } diff --git a/citizen-portal/src/lib/businessModules/stiProtection/api/clients.ts b/citizen-portal/src/lib/businessModules/stiProtection/api/clients.ts new file mode 100644 index 0000000000000000000000000000000000000000..815536a0d41595da0fb25927f3dad948153e30cc --- /dev/null +++ b/citizen-portal/src/lib/businessModules/stiProtection/api/clients.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useApiConfiguration } from "@eshg/lib-portal/api/ApiProvider"; +import { CitizenPublicApi, Configuration } from "@eshg/sti-protection-api"; + +function useConfiguration() { + const configurationParameters = useApiConfiguration( + "PUBLIC_STI_PROTECTION_BACKEND_URL", + ); + return new Configuration(configurationParameters); +} + +// export function useCitizenPrivateApi() { +// const configuration = useConfiguration(); +// return new CitizenPrivateApi(configuration); +// } + +export function useCitizenPublicApi() { + const configuration = useConfiguration(); + return new CitizenPublicApi(configuration); +} diff --git a/citizen-portal/src/lib/businessModules/stiProtection/api/queries/apiQueryKeys.ts b/citizen-portal/src/lib/businessModules/stiProtection/api/queries/apiQueryKeys.ts new file mode 100644 index 0000000000000000000000000000000000000000..a589cb8549fed905ccf51cee9ed1e69c1e75f07c --- /dev/null +++ b/citizen-portal/src/lib/businessModules/stiProtection/api/queries/apiQueryKeys.ts @@ -0,0 +1,16 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { queryKeyFactory } from "@eshg/lib-portal/api/queryKeyFactory"; + +const apiQueryKey = queryKeyFactory(["stiProtection"]); + +export const stiProtectionCitizenApiQueryKey = queryKeyFactory( + apiQueryKey(["stiProtectionCitizenApi"]), +); + +export const stiProtectionPublicCitizenApiQueryKey = queryKeyFactory( + apiQueryKey(["stiProtectionPublicCitizenApi"]), +); diff --git a/citizen-portal/src/lib/businessModules/stiProtection/api/queries/publicCitizenApi.ts b/citizen-portal/src/lib/businessModules/stiProtection/api/queries/publicCitizenApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..0bd08875ad5c3e6a76bde322354de327c2d4e12f --- /dev/null +++ b/citizen-portal/src/lib/businessModules/stiProtection/api/queries/publicCitizenApi.ts @@ -0,0 +1,37 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ApiConcern } from "@eshg/sti-protection-api"; +import { queryOptions, useSuspenseQuery } from "@tanstack/react-query"; + +import { useCitizenPublicApi } from "@/lib/businessModules/stiProtection/api/clients"; +import { stiProtectionPublicCitizenApiQueryKey } from "@/lib/businessModules/stiProtection/api/queries/apiQueryKeys"; + +export function useDepartmentInfoQuery(concern: ApiConcern) { + const publicCitizenApi = useCitizenPublicApi(); + return queryOptions({ + queryKey: stiProtectionPublicCitizenApiQueryKey([ + "departmentInfo", + concern, + ]), + queryFn: () => publicCitizenApi.getDepartmentInfo(concern), + }); +} + +export function useDepartmentInfo(concern: ApiConcern) { + return useSuspenseQuery(useDepartmentInfoQuery(concern)); +} + +export function useOpeningHoursQuery(concern: ApiConcern) { + const publicCitizenApi = useCitizenPublicApi(); + return queryOptions({ + queryKey: stiProtectionPublicCitizenApiQueryKey(["openingHours", concern]), + queryFn: () => publicCitizenApi.getOpeningHours(concern), + }); +} + +export function useOpeningHours(concern: ApiConcern) { + return useSuspenseQuery(useOpeningHoursQuery(concern)); +} diff --git a/citizen-portal/src/lib/businessModules/stiProtection/locales/de/appointment.json b/citizen-portal/src/lib/businessModules/stiProtection/locales/de/appointment.json new file mode 100644 index 0000000000000000000000000000000000000000..32583522f7c9ef2cd85dca8d7d78ead7ebb21b70 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/stiProtection/locales/de/appointment.json @@ -0,0 +1,41 @@ +{ + "pageTitle": "Mein Termin", + "procedureClosed": { + "title": "Vorgang geschlossen", + "message": "Der Termin kann nicht mehr geändert werden, da der Vorgang geschlossen wurde." + }, + "leave": "Mein Bereich verlassen", + "details": { + "title": "Informationen", + "name": "Name", + "birthday": "Geburtstag", + "medicalService": "Leistungsart", + "schoolEntryExamination": "Einschulungsuntersuchung", + "date": "Datum", + "time": "Zeit", + "clock": "Uhr", + "duration": "Dauer:", + "place": "Ort" + }, + "preparations": "Vorbereitung zum Termin", + "required": { + "title": "Benötigte Unterlagen", + "anamnesis": "ausgefüllter Elternfragebogen", + "vaccinationCard": "Impfausweis", + "medicalRecords": "das gelbe Vorsorgeheft (U-Heft)", + "additionalDocuments": "gegebenenfalls weitere medizinische Unterlagen, evtl. aktuelle Medikation", + "additionalAids": "gegebenenfalls vorhandene Hilfsmittel (Brille, Hörgerät)" + }, + "anamnesis": { + "title": "Elternfragebogen", + "notSubmitted": "noch nicht vorgelegt", + "submitted": "ausgefüllt", + "fillIn": "Jetzt ausfüllen" + }, + "update": { + "title": "Sie können den Termin nicht wahrnehmen?", + "appointment": "Termin verschieben", + "alert": "Terminverschiebung nicht mehr möglich", + "alertMessage": "Sie haben Ihren Termin bereits zwei Mal umgebucht. Bitte wenden Sie sich an unseren Support unter {{phoneNumber}}." + } +} diff --git a/citizen-portal/src/lib/businessModules/stiProtection/locales/de/nav.json b/citizen-portal/src/lib/businessModules/stiProtection/locales/de/nav.json new file mode 100644 index 0000000000000000000000000000000000000000..145d35831de616e5ccbd1daa86a6927bc55e68d1 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/stiProtection/locales/de/nav.json @@ -0,0 +1,7 @@ +{ + "sti_protection_title": "Sexuelle Gesundheit / STI", + "landing": { + "sti_consultation_title": "HIV-STI-Beratung", + "sex_work_title": "Sexarbeit" + } +} diff --git a/citizen-portal/src/lib/businessModules/stiProtection/locales/de/overview.json b/citizen-portal/src/lib/businessModules/stiProtection/locales/de/overview.json new file mode 100644 index 0000000000000000000000000000000000000000..1532a64db950ca481c1315fbdf1f5b60fd97cb28 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/stiProtection/locales/de/overview.json @@ -0,0 +1,52 @@ +{ + "page_title_sex_work": "Sexarbeit", + "page_title_sti_consultation": "HIV-STI-Beratung", + "information": { + "title": "Beratungs- und Testangebote", + "notice": "Sie benötigen weder ein Ausweisdokument noch eine Versicherungskarte. Alle Testergebnisse werden nur persönlich mitgeteilt.", + "applies_to_heading": "Das Angebot richtet sich an", + "applies_to_list": [ + "Menschen ohne Krankenversicherung", + "Sexarbeiterinnen und Sexarbeiter", + "Allgemeinbevölkerung (Sie können sich bei uns nur beraten und testen lassen, eine Untersuchung können wir leider nicht vornehmen)" + ], + "tests_available_heading": "Je nach Vorgeschichte und Beschwerden können weitere Tests durchgeführt werden", + "tests_available_list": [ + "körperliche Untersuchung", + "Blut- und Urinuntersuchung", + "Abstriche" + ], + "costs_heading": "Kosten", + "costs_info": "Die Kosten für die Laboruntersuchungen müssen von Ihnen selbst übernommen werden.", + "exceptions_heading": "Ausnahme", + "exceptions_list": [ + "Jugendliche bis 24 Jahre können sich kostenlos auf HIV und Clamydien testen lassen.", + "Wenn Sie in der Sexarbeit tätig oder nicht krankenversichert sind, können Sie unsere Leistungen kostenlos in Anspruch nehmen." + ], + "invitation": "Den Termin zur Einschulungsuntersuchung verschickt das Gesundheitsamt per Post mit einem Einladungsschreiben etwa 3 bis 4 Wochen vor dem Termin.", + "cancellation": "Falls dieser Termin nicht wahrgenommen werden kann, sollte er rechtzeitig verschoben werden. Dies können Sie hier über das Online-Portal bequem selbst erledigen. Mithilfe des Anmeldecodes auf der Einladung und dem Geburtstag Ihres Kindes erreichen Sie Ihren persönlichen Bereich.", + "location": "Die Einschulungsuntersuchung findet im Gesundheitsamt in der {{address}} statt. Bitte im Eingangsbereich am Empfang melden und das Einladungsschreiben vorzeigen." + }, + "contact": { + "title": "Kontakt und Erreichbarkeit", + "address_section": { + "title": "Adresse" + }, + "opening_hours_section": { + "title": "Öffnungs- und Sprechzeiten" + }, + "phone_section": { + "title": "Telefon", + "number": "Telefon: {{phoneNumber}}" + }, + "email_section": { + "title": "E-Mail-Adresse" + } + }, + "personal_area": { + "title": "Möchten Sie einen Termin vereinbaren?", + "information": "Termine für die Beratung und Testung können online vereinbart werden. Termine werden immer zwei Wochen im Voraus freigeschaltet.", + "create_appointment": "Zur Terminvereinbarung", + "go_to_personal_area": "Zu meinen Terminen" + } +} diff --git a/citizen-portal/src/lib/businessModules/stiProtection/locales/de/updateAppointment.json b/citizen-portal/src/lib/businessModules/stiProtection/locales/de/updateAppointment.json new file mode 100644 index 0000000000000000000000000000000000000000..8c834bf90976173497ded54cd699dd1975429aab --- /dev/null +++ b/citizen-portal/src/lib/businessModules/stiProtection/locales/de/updateAppointment.json @@ -0,0 +1,14 @@ +{ + "title": "Verfügbare Termine", + "notAvailable": "Aktuell keine Termine verfügbar", + "notAvailableMessage": "Unsere Mitarbeiter:innen des Gesundheitsamtes wurden bereits in Kenntnis gesetzt. Bitte versuchen Sie es zu einem späteren Zeitpunkt erneut.", + "available": "Terminverschiebung", + "availableMessage": "Sie können Ihren Termin noch {{changesLeft}} Mal verschieben.", + "result": { + "title": "Übersicht", + "name": "Name", + "birthday": "Geburtstag", + "confirm": "Termin verbindlich buchen", + "back": "Zurück" + } +} diff --git a/citizen-portal/src/lib/businessModules/stiProtection/locales/en/appointment.json b/citizen-portal/src/lib/businessModules/stiProtection/locales/en/appointment.json new file mode 100644 index 0000000000000000000000000000000000000000..a8e26cbb6d27b7dbf866c7b313d45b78a1d92539 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/stiProtection/locales/en/appointment.json @@ -0,0 +1,41 @@ +{ + "pageTitle": "My Appointment", + "procedureClosed": { + "title": "Process Closed", + "message": "The appointment can no longer be changed as the process has been closed." + }, + "leave": "Logout", + "details": { + "title": "Information", + "name": "Name", + "birthday": "Date of Birth", + "medicalService": "Type of Service", + "schoolEntryExamination": "School Enrollment Examination", + "date": "Date", + "time": "Time", + "clock": "", + "duration": "Duration", + "place": "Location" + }, + "preparations": "Appointment Preparations", + "required": { + "title": "Required Documents", + "anamnesis": "Completed Parent Questionnaire", + "vaccinationCard": "Vaccination Card", + "medicalRecords": "The yellow medical record booklet (U-Heft)", + "additionalDocuments": "If applicable, additional medical documents, possibly current medication", + "additionalAids": "If applicable, available aids (glasses, hearing aid)" + }, + "anamnesis": { + "title": "Parent Questionnaire", + "notSubmitted": "Not yet submitted", + "submitted": "Submitted", + "fillIn": "Fill out Now" + }, + "update": { + "title": "Can't make the appointment?", + "appointment": "Reschedule Appointment", + "alert": "Rescheduling no longer possible", + "alertMessage": "You have already rescheduled your appointment twice. Please contact our support at {{phoneNumber}}." + } +} diff --git a/citizen-portal/src/lib/businessModules/stiProtection/locales/en/nav.json b/citizen-portal/src/lib/businessModules/stiProtection/locales/en/nav.json new file mode 100644 index 0000000000000000000000000000000000000000..87a5e5e91ceb6107c695e072f0acf52c9c6fb8ad --- /dev/null +++ b/citizen-portal/src/lib/businessModules/stiProtection/locales/en/nav.json @@ -0,0 +1,7 @@ +{ + "sti_protection_title": "Sexual health / STI", + "landing": { + "sti_consultation_title": "HIV / STI Consultation", + "sex_work_title": "Sex work" + } +} diff --git a/citizen-portal/src/lib/businessModules/stiProtection/locales/en/overview.json b/citizen-portal/src/lib/businessModules/stiProtection/locales/en/overview.json new file mode 100644 index 0000000000000000000000000000000000000000..ba5f6796f745c9b3f905a64341b70fccefceb0cc --- /dev/null +++ b/citizen-portal/src/lib/businessModules/stiProtection/locales/en/overview.json @@ -0,0 +1,52 @@ +{ + "page_title_sex_work": "Sex Work", + "page_title_sti_consultation": "HIV-STI Consultation", + "information": { + "title": "Consulting and testing services", + "notice": "You do not need an ID document or an insurance card. All test results are only communicated personally.", + "applies_to_heading": "The offer is aimed at", + "applies_to_list": [ + "People without health insurance", + "Sex workers", + "General population (you can only get advice and test with us, unfortunately we cannot make an examination)" + ], + "tests_available_heading": "Depending on the history and complaints, further tests can be carried out", + "tests_available_list": [ + "Physical examinations", + "Blood and urine examinations", + "Smears" + ], + "costs_heading": "Cost", + "costs_info": "The costs for the laboratory tests must be covered by individuals themselves.", + "exceptions_heading": "Exceptions", + "exceptions_list": [ + "Young people up to 24 years can be tested free of charge for HIV and chlamydia.", + "If you work in sex work or not insured, you can use our services free of charge." + ], + "invitation": "You can be tested anonymously for HIV and sexually transmitted infections, including hepatitis B and C, and receive advice. You can also ask questions about partnership, contraception or sexual orientation confidentially and anonymously.", + "cancellation": "If you are unable to attend this appointment, it should be rescheduled in a timely manner. You can conveniently do this yourself here via the online portal. Using the registration code on the invitation and your child's date of birth, you can access your personal area.", + "location": "The school enrollment examination takes place at the health department at {{address}}. Please report to the reception at the entrance and present the invitation letter." + }, + "contact": { + "title": "Contact and Availability", + "address_section": { + "title": "Address" + }, + "opening_hours_section": { + "title": "Opening and Consultation Hours" + }, + "phone_section": { + "title": "Phone", + "number": "Phone: {{phoneNumber}}" + }, + "email_section": { + "title": "Email Address" + } + }, + "personal_area": { + "title": "Would you like to make an appointment?", + "information": "Appointments for consultation and testing can be arranged online. Appointments are always available two weeks in advance.", + "create_appointment": "Book new appointment", + "go_to_personal_area": "View my appointment" + } +} diff --git a/citizen-portal/src/lib/businessModules/stiProtection/locales/en/updateAppointment.json b/citizen-portal/src/lib/businessModules/stiProtection/locales/en/updateAppointment.json new file mode 100644 index 0000000000000000000000000000000000000000..d27d5d2ab28d186574b7fa211d776ebff91e4d3c --- /dev/null +++ b/citizen-portal/src/lib/businessModules/stiProtection/locales/en/updateAppointment.json @@ -0,0 +1,14 @@ +{ + "title": "Available Appointments", + "notAvailable": "No appointments currently available", + "notAvailableMessage": "Our Health Department staff have already been informed. Please try again later.", + "available": "Reschedule Appointment", + "availableMessage": "You are allowed to reschedule {{changesLeft}} more times.", + "result": { + "title": "Overview", + "name": "Name", + "birthday": "Date of Birth", + "confirm": "Confirm Appointment", + "back": "Back" + } +} diff --git a/citizen-portal/src/lib/businessModules/stiProtection/pages/landingpage/LandingpageContent.tsx b/citizen-portal/src/lib/businessModules/stiProtection/pages/landingpage/LandingpageContent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5a2094b1d8f552c736035fb5b0da2550e5ac103f --- /dev/null +++ b/citizen-portal/src/lib/businessModules/stiProtection/pages/landingpage/LandingpageContent.tsx @@ -0,0 +1,142 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ExternalLink } from "@eshg/lib-portal/components/navigation/ExternalLink"; +import { ApiConcern } from "@eshg/sti-protection-api"; +import { CallOutlined, MailOutlineOutlined } from "@mui/icons-material"; +import { Box, Typography } from "@mui/joy"; + +import { + useDepartmentInfo, + useOpeningHours, +} from "@/lib/businessModules/stiProtection/api/queries/publicCitizenApi"; +import { useTranslation } from "@/lib/i18n/client"; +import { AddressSection } from "@/lib/shared/components/AddressSection"; +import { OpeningHoursSection } from "@/lib/shared/components/OpeningHoursSection"; +import { + InfoSection, + InfoSectionGrid, + InfoSectionTitle, +} from "@/lib/shared/components/infoSection"; +import { + ContentSheet, + ContentSheetTitle, +} from "@/lib/shared/components/layout/contentSheet"; +import { GridColumnStack } from "@/lib/shared/components/layout/grid"; +import { DepartmentInfoProps } from "@/lib/shared/types"; + +interface LandingpageContentProps { + concern: ApiConcern; +} + +export function LandingpageContent({ concern }: LandingpageContentProps) { + const { t } = useTranslation("stiProtection/overview"); + const { data: departmentInfo } = useDepartmentInfo(concern); + const { data: openingHours } = useOpeningHours(concern); + + return ( + <GridColumnStack> + <ContentSheet> + <ContentSheetTitle>{t("information.title")}</ContentSheetTitle> + <Typography>{t("information.invitation")}</Typography> + <Typography>{t("information.cancellation")}</Typography> + + <TranslatedList + baseKey="information" + headingKey="applies_to_heading" + listKey="applies_to_list" + length={3} + /> + + <TranslatedList + baseKey="information" + headingKey="tests_available_heading" + listKey="tests_available_list" + length={3} + /> + + <TranslatedList + baseKey="information" + headingKey="exceptions_heading" + listKey="exceptions_list" + length={2} + /> + </ContentSheet> + <ContentSheet> + <ContentSheetTitle>{t("contact.title")}</ContentSheetTitle> + <InfoSectionGrid> + <AddressSection + department={departmentInfo} + localePath="stiProtection/overview" + /> + <OpeningHoursSection + openingHours={openingHours} + localePath="stiProtection/overview" + /> + <PhoneNumbersSection department={departmentInfo} /> + <EmailSection department={departmentInfo} /> + </InfoSectionGrid> + </ContentSheet> + </GridColumnStack> + ); +} + +function PhoneNumbersSection({ department }: DepartmentInfoProps) { + const { t } = useTranslation("stiProtection/overview"); + return ( + <InfoSection icon={<CallOutlined />}> + <InfoSectionTitle>{t("contact.phone_section.title")}</InfoSectionTitle> + <Typography> + {t("contact.phone_section.number", { + phoneNumber: department.phoneNumber, + })} + </Typography> + </InfoSection> + ); +} + +function EmailSection({ department }: DepartmentInfoProps) { + const { t } = useTranslation("stiProtection/overview"); + const email = department.email; + return ( + <InfoSection icon={<MailOutlineOutlined />}> + <InfoSectionTitle>{t("contact.email_section.title")}</InfoSectionTitle> + <ExternalLink href={`mailto:${email}`}>{email}</ExternalLink> + </InfoSection> + ); +} + +interface TranslatedListProps { + baseKey: string; + headingKey: string; + listKey: string; + length: number; +} +function TranslatedList({ + baseKey, + headingKey, + listKey, + length, +}: TranslatedListProps) { + const { t } = useTranslation("stiProtection/overview"); + return ( + <div> + <Typography level="title-md">{t(`${baseKey}.${headingKey}`)}</Typography> + <Box component="ul" sx={{ margin: 1, paddingLeft: 2 }}> + {Array(length) + .fill(0) + .map((_, index) => ( + <Typography + key={index} + component="li" + sx={{ display: "list-item" }} + > + {t(`${baseKey}.${listKey}.${index}`)} + </Typography> + ))} + </Box> + </div> + ); +} diff --git a/citizen-portal/src/lib/businessModules/stiProtection/pages/landingpage/LandingpageSidePanel.tsx b/citizen-portal/src/lib/businessModules/stiProtection/pages/landingpage/LandingpageSidePanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3eca5e518872e6fbb4eacfbd636d0271d58a832e --- /dev/null +++ b/citizen-portal/src/lib/businessModules/stiProtection/pages/landingpage/LandingpageSidePanel.tsx @@ -0,0 +1,39 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { InternalLinkButton } from "@eshg/lib-portal/components/navigation/InternalLinkButton"; +import { Stack, Typography } from "@mui/joy"; + +import { useCitizenRoutes } from "@/lib/businessModules/stiProtection/shared/routes"; +import { useTranslation } from "@/lib/i18n/client"; +import { + ContentSheet, + ContentSheetTitle, +} from "@/lib/shared/components/layout/contentSheet"; +import { useAccessCodeParam } from "@/lib/shared/helpers/accessCode"; + +export function LandingpageSidePanel() { + const { t } = useTranslation(["stiProtection/overview"]); + const accessCode = useAccessCodeParam(); + const citizenRoutes = useCitizenRoutes(); + + return ( + <ContentSheet> + <ContentSheetTitle>{t("personal_area.title")}</ContentSheetTitle> + <Typography>{t("personal_area.information")}</Typography> + <Stack gap={2}> + <InternalLinkButton href={citizenRoutes.appointments.index(undefined)}> + {t("personal_area.create_appointment")} + </InternalLinkButton> + <InternalLinkButton + href={citizenRoutes.appointments.index(accessCode)} + variant="outlined" + > + {t("personal_area.go_to_personal_area")} + </InternalLinkButton> + </Stack> + </ContentSheet> + ); +} diff --git a/citizen-portal/src/lib/businessModules/stiProtection/shared/navigationItems.tsx b/citizen-portal/src/lib/businessModules/stiProtection/shared/navigationItems.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e27ea27c28ee111c481dfa82d11c6c485c87a818 --- /dev/null +++ b/citizen-portal/src/lib/businessModules/stiProtection/shared/navigationItems.tsx @@ -0,0 +1,37 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { StickyNote2Outlined } from "@mui/icons-material"; + +import { NavigationItem } from "@/lib/baseModule/components/layout/types"; +import { useTranslation } from "@/lib/i18n/client"; + +import { useCitizenRoutes } from "./routes"; + +export function useCitizenNavigationItems(): NavigationItem[] { + const citizenRoutes = useCitizenRoutes(); + const { t } = useTranslation("stiProtection/nav"); + return [ + { + name: t("sti_protection_title"), + subItems: [ + { + name: t("landing.sti_consultation_title"), + href: citizenRoutes.stiConsultation, + icon: StickyNote2Outlined, + }, + { + name: t("landing.sex_work_title"), + href: citizenRoutes.sexWork, + icon: StickyNote2Outlined, + }, + ], + }, + ]; +} + +export function useOrganizationNavigationItems(): NavigationItem[] { + return []; +} diff --git a/citizen-portal/src/lib/businessModules/stiProtection/shared/routes.ts b/citizen-portal/src/lib/businessModules/stiProtection/shared/routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb832e1b6763140fc41b0898d0b9e3abe7f1821e --- /dev/null +++ b/citizen-portal/src/lib/businessModules/stiProtection/shared/routes.ts @@ -0,0 +1,34 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineRoutes } from "@eshg/lib-portal/helpers/routes"; + +import { routes as baseRoutes } from "@/lib/baseModule/shared/routes"; +import { SupportedLanguage } from "@/lib/i18n/options"; +import { useGivenLang } from "@/lib/i18n/useLang"; +import { accessCodeRoute } from "@/lib/shared/helpers/accessCode"; + +export function citizenRoutes(locale: SupportedLanguage | undefined) { + return defineRoutes( + `${baseRoutes(locale).citizenPath.index}/sexuelle-gesundheit`, + (stiProtectionPath) => ({ + sexWork: stiProtectionPath("/sexarbeit"), + stiConsultation: stiProtectionPath("/sti-beratung"), + appointments: defineRoutes( + stiProtectionPath("/meine-termine"), + (appointmentPath) => ({ + index: accessCodeRoute(appointmentPath("/")), + }), + ), + }), + ); +} + +export type CitizenRoutes = ReturnType<typeof citizenRoutes>; + +export function useCitizenRoutes() { + const locale = useGivenLang(); + return citizenRoutes(locale); +} diff --git a/citizen-portal/src/lib/businessModules/travelMedicine/components/shared/components/FormSheet.tsx b/citizen-portal/src/lib/businessModules/travelMedicine/components/shared/components/FormSheet.tsx index 37da087504a25eff3fd139b0412536bd4c21721f..ac384f5f3687bf2f8dfa70b5653238d1d8a01c78 100644 --- a/citizen-portal/src/lib/businessModules/travelMedicine/components/shared/components/FormSheet.tsx +++ b/citizen-portal/src/lib/businessModules/travelMedicine/components/shared/components/FormSheet.tsx @@ -7,6 +7,7 @@ import { RequiresChildren } from "@eshg/lib-portal/types/react"; import { Sheet, Stack, Typography } from "@mui/joy"; import { theme } from "@/lib/baseModule/theme/theme"; +import { useIsMobile } from "@/lib/shared/hooks/useIsMobile"; interface FormSheetProps extends RequiresChildren { "data-testid"?: string; @@ -33,8 +34,10 @@ interface FormSheetTitleProps extends RequiresChildren { } export function FormSheetTitle(props: FormSheetTitleProps) { + const isMobile = useIsMobile(); + return ( - <Stack> + <Stack gap={isMobile ? 1 : 0}> <Typography level="h2">{props.children}</Typography> {props.requiredTitle && ( <Typography diff --git a/citizen-portal/src/lib/businessModules/travelMedicine/helpers/translations.ts b/citizen-portal/src/lib/businessModules/travelMedicine/helpers/translations.ts index c710eff1e596fb5e7a5068cb476acf0624aaa1ec..bb166db464892651a9e63f5a689bebc2f7b0449f 100644 --- a/citizen-portal/src/lib/businessModules/travelMedicine/helpers/translations.ts +++ b/citizen-portal/src/lib/businessModules/travelMedicine/helpers/translations.ts @@ -36,5 +36,6 @@ export const APPOINTMENT_TYPE: EnumMap<ApiAppointmentType> = { [ApiAppointmentType.HivStiConsultation]: "HIV-STI-Beratung", [ApiAppointmentType.SexWork]: "Sexarbeit", [ApiAppointmentType.ResultsReview]: "Ergebnisbesprechung", - [ApiAppointmentType.OfficialMedicalService]: "Amtsärtzlicher Dienst", + [ApiAppointmentType.OfficialMedicalServiceShort]: "Kleine Untersuchung", + [ApiAppointmentType.OfficialMedicalServiceLong]: "Große Untersuchung", }; diff --git a/citizen-portal/src/lib/i18n/client.ts b/citizen-portal/src/lib/i18n/client.ts index 8bf15912722bfecd6483979b13fcf468bad5d42b..cf132dbd0d44b9d58680960fc43ea38ec3fcd9a2 100644 --- a/citizen-portal/src/lib/i18n/client.ts +++ b/citizen-portal/src/lib/i18n/client.ts @@ -13,6 +13,7 @@ import { initReactI18next, useTranslation, } from "react-i18next"; +import { flat, isArray, pipe, unique } from "remeda"; import { options, @@ -42,6 +43,7 @@ function createClient(lang: string) { void client.init(); return client; } + function useTranslationWrapper( ns?: string | string[], options?: UseTranslationOptions<undefined>, @@ -62,8 +64,45 @@ function useTranslationWrapper( }, [i18n, t], ); - return { t: tFunction, i18n, ready }; + return { t: useTWithCamelCase(tFunction), i18n, ready }; +} + +export type TranslateFn = ( + key: string | string[], + tOptions?: TOptions, +) => string; + +function fromSnakeToCamel(snakeCase: string): string { + return snakeCase + .split(".") + .map((keyPart) => { + const words = keyPart.split("_"); + const capitalizedWords = words + .slice(1) + .map((t) => t[0]?.toUpperCase() + t.slice(1)); + return [words[0], ...capitalizedWords].join(""); + }) + .join("."); +} + +export function useTWithCamelCase(t: TranslateFn): TranslateFn { + return useCallback( + (args, tOptions) => { + const keys: string[] = (isArray(args) ? args : [args]).filter( + (t) => t != null, + ); + if (keys.length === 0) { + return t(args, tOptions); + } + const newKeys: string[] = pipe( + keys.map((k) => [k, fromSnakeToCamel(k)]), + flat(), + unique(), + ); + return t(newKeys, tOptions); + }, + [t], + ); } -export type TranslateFn = ReturnType<typeof useTranslationWrapper>["t"]; export { useTranslationWrapper as useTranslation }; diff --git a/citizen-portal/src/lib/shared/components/AddressSection.tsx b/citizen-portal/src/lib/shared/components/AddressSection.tsx index 3597ac29fdf551baa844dd6104eb76b35c2ecb7f..2ac18e289c2993095ad7063f0b03f63838d29eae 100644 --- a/citizen-portal/src/lib/shared/components/AddressSection.tsx +++ b/citizen-portal/src/lib/shared/components/AddressSection.tsx @@ -30,7 +30,7 @@ export function AddressSection({ return ( <InfoSection icon={<FmdGoodOutlined />}> - <InfoSectionTitle>{t("contact.addressSection.title")}</InfoSectionTitle> + <InfoSectionTitle>{t("contact.address_section.title")}</InfoSectionTitle> <Typography> {department.name} <br /> diff --git a/citizen-portal/src/lib/shared/components/OpeningHoursSection.tsx b/citizen-portal/src/lib/shared/components/OpeningHoursSection.tsx index 0fd5f594b4837741142309becd6ad240586931ed..968e96a9c10954966435aa79e0b708aa844220b0 100644 --- a/citizen-portal/src/lib/shared/components/OpeningHoursSection.tsx +++ b/citizen-portal/src/lib/shared/components/OpeningHoursSection.tsx @@ -5,8 +5,8 @@ import { ApiGetOpeningHoursResponse } from "@eshg/travel-medicine-api"; import { AccessTimeOutlined } from "@mui/icons-material"; -import { Typography } from "@mui/joy"; -import { isDefined } from "remeda"; +import { Stack, Typography, styled } from "@mui/joy"; +import { isDefined, map, partition, pipe, zip } from "remeda"; import { useTranslation } from "@/lib/i18n/client"; import { @@ -25,29 +25,77 @@ export function OpeningHoursSection({ }: Readonly<OpeningHoursSectionProps>) { const { t, i18n } = useTranslation([`${localePath}`]); + const hasOpeningHours = isDefined(openingHours); let openingHoursInSelectedLanguage; - if (isDefined(openingHours)) { + if (hasOpeningHours) { if (i18n.language === "de") { openingHoursInSelectedLanguage = openingHours.de; } else { openingHoursInSelectedLanguage = openingHours.en; } } - + const [periods, availabilities] = partition( + openingHoursInSelectedLanguage ?? [], + (_, index) => index % 2 === 0, + ); + const pairedAvailability = pipe( + periods, + zip(availabilities), + map( + ([period, availability]) => [period, availability.split("\n")] as const, + ), + ); return ( <InfoSection icon={<AccessTimeOutlined />}> <InfoSectionTitle> - {t("contact.openingHoursSection.title")} + {t("contact.opening_hours_section.title")} </InfoSectionTitle> - {openingHours && openingHoursInSelectedLanguage ? ( - openingHoursInSelectedLanguage.map((openingHour) => ( - <Typography sx={{ margin: 0 }} key={openingHour}> - {openingHour} - </Typography> - )) + {hasOpeningHours ? ( + <Stack component="dl" sx={{ margin: 0 }}> + {pairedAvailability.map(([period, availabilities]) => ( + <OpeningTime + key={period} + period={period} + availabilities={availabilities} + /> + ))} + </Stack> ) : ( - <Typography>{t("contact.openingHoursSection.information")}</Typography> + <Typography> + {t("contact.opening_hours_section.information")} + </Typography> )} </InfoSection> ); } + +const OpeningTimePair = styled("div")(({ theme }) => ({ + display: "grid", + gridTemplateColumns: "auto 1fr", + gap: theme.spacing(2), + margin: 0, +})); + +function OpeningTime({ + period, + availabilities, +}: { + period: string; + availabilities: string[]; +}) { + return ( + <OpeningTimePair> + <Typography component="dt" sx={{ marginRight: 1 }}> + {period} + </Typography> + <Typography component="dd" sx={{ margin: 0 }}> + {availabilities.map((t, index) => ( + <> + {t} + {index !== availabilities.length - 1 ? <br /> : null} + </> + ))} + </Typography> + </OpeningTimePair> + ); +} diff --git a/citizen-portal/src/lib/shared/components/form/file/buttonVariants.tsx b/citizen-portal/src/lib/shared/components/form/file/buttonVariants.tsx index b9b78a3acb8166ac6751df1dd84ea4769d196f76..c3b90c8ef6746de658416c78ed3d1f6bdd37eba0 100644 --- a/citizen-portal/src/lib/shared/components/form/file/buttonVariants.tsx +++ b/citizen-portal/src/lib/shared/components/form/file/buttonVariants.tsx @@ -10,7 +10,7 @@ export const StyledButton = styled(Button)(({ theme }) => ({ padding: theme.spacing(1, 6), })); -interface FileButtonProps +export interface FileButtonProps extends Pick< ButtonProps, | "sx" diff --git a/citizen-portal/src/lib/shared/components/layout/grid.tsx b/citizen-portal/src/lib/shared/components/layout/grid.tsx index 936c99935e059316d9a59a3b9b889493d10f63d3..502767c2ef2cbb23e11e989d70abd7e9edef939c 100644 --- a/citizen-portal/src/lib/shared/components/layout/grid.tsx +++ b/citizen-portal/src/lib/shared/components/layout/grid.tsx @@ -49,7 +49,7 @@ export function ThreeColumnGrid(props: ThreeColumnGridProps) { interface OneColumnGridProps { contentTop: ReactNode; contentCenter: ReactNode; - contentBottom: ReactNode; + contentBottom?: ReactNode; } export function OneColumnGrid(props: OneColumnGridProps) { @@ -57,7 +57,9 @@ export function OneColumnGrid(props: OneColumnGridProps) { <Grid container columns={GRID_COLUMNS} spacing={GRID_SPACING}> <Grid {...allBreakpoints(1)}>{props.contentTop}</Grid> <Grid {...allBreakpoints(1)}>{props.contentCenter}</Grid> - <Grid {...allBreakpoints(1)}>{props.contentBottom}</Grid> + {props.contentBottom && ( + <Grid {...allBreakpoints(1)}>{props.contentBottom}</Grid> + )} </Grid> ); } diff --git a/config/tsup.base.ts b/config/tsup.base.ts index 99e58552d43e4ff32340cc7b68943f20b7e0220b..dbacc46f8f682479ea5c10a28cecd3a85c41b753 100644 --- a/config/tsup.base.ts +++ b/config/tsup.base.ts @@ -13,10 +13,14 @@ const baseOptions: Options = { }; const excludeUnitTestsPattern = "!src/**/*.test.*"; -export function defineLibConfig(entry: string[]) { +export function defineLibConfig( + entry: string[], + platform?: Options["platform"], +) { return defineConfig((options) => ({ - entry: [...entry, excludeUnitTestsPattern], ...baseOptions, + entry: [...entry, excludeUnitTestsPattern], + platform, ...options, })); } diff --git a/config/vitest.base.ts b/config/vitest.base.ts index f445badb922e7d209dfcc43b93c2facdf06cbbf2..9b8e1f6e34cd54bfa270a2b6ea2c4d62dee94f15 100644 --- a/config/vitest.base.ts +++ b/config/vitest.base.ts @@ -5,13 +5,13 @@ import react from "@vitejs/plugin-react"; import tsconfigPaths from "vite-tsconfig-paths"; -import { UserConfigExport, configDefaults } from "vitest/config"; +import { ViteUserConfig, configDefaults } from "vitest/config"; export const VITEST_OUT_DIR = "./build/vitest"; export const VITEST_COVERAGE_EXCLUDES = ["**/*.d.ts"]; // https://vitejs.dev/config/ -export const VITEST_BASE_CONFIG: UserConfigExport = { +export const VITEST_BASE_CONFIG: ViteUserConfig = { plugins: [react(), tsconfigPaths()], test: { exclude: configDefaults.exclude, diff --git a/employee-portal/gradleDependencies.json b/employee-portal/gradleDependencies.json index 7bc579885c823be0a2c71509e86beef500d02567..225813586de91121ae4b53bb84705a7972fd599e 100644 --- a/employee-portal/gradleDependencies.json +++ b/employee-portal/gradleDependencies.json @@ -11,6 +11,7 @@ ":lib-portal", ":lib-procedures-api", ":lib-statistics-api", + ":lib-vitest", ":measles-protection-api", ":medical-registry-api", ":official-medical-service-api", diff --git a/employee-portal/package.json b/employee-portal/package.json index 780032375904543ded5de4b40bfe3eff3e6cb36d..3b56fb9f3080c7589795a161519123e6ca3a7229 100644 --- a/employee-portal/package.json +++ b/employee-portal/package.json @@ -49,11 +49,12 @@ "formik": "catalog:common", "hpke-js": "1.6.1", "iso8601-duration": "2.1.2", - "matrix-js-sdk": "34.13.0", + "matrix-js-sdk": "36.2.0", "next": "catalog:next", "react": "catalog:react", "react-dom": "catalog:react", "react-error-boundary": "catalog:common", + "react-idle-timer": "^5.7.2", "react-infinite-scroll-hook": "5.0.1", "remeda": "catalog:common", "server-only": "catalog:common", @@ -66,6 +67,7 @@ "zustand": "catalog:common" }, "devDependencies": { + "@eshg/lib-vitest": "workspace:*", "@eslint/compat": "catalog:eslint", "@eslint/eslintrc": "catalog:eslint", "@next/bundle-analyzer": "catalog:next", diff --git a/employee-portal/src/app/(baseModule)/(static)/[documentType]/page.tsx b/employee-portal/src/app/(baseModule)/(static)/[documentType]/page.tsx index 6168e214e1605f5473e83cdb96b739074b89c2e9..c1ec27bc980a6f689c3efbfebc53a04b5933d2c1 100644 --- a/employee-portal/src/app/(baseModule)/(static)/[documentType]/page.tsx +++ b/employee-portal/src/app/(baseModule)/(static)/[documentType]/page.tsx @@ -3,6 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import NotFound from "@/app/not-found"; import { StaticTextDocumentPanel } from "@/lib/baseModule/components/StaticTextDocumentPanel"; import { @@ -10,9 +14,6 @@ import { PageName, isValidPageType, } from "@/lib/baseModule/components/markdown/MarkdownPage"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; const title = { contact: "Kontakt", diff --git a/employee-portal/src/app/(baseModule)/(static)/acknowledgements/page.tsx b/employee-portal/src/app/(baseModule)/(static)/acknowledgements/page.tsx index 87d66d5082c8f351b5f0b379ca0de74e0294675b..b03ac8f7bdf410a4c8a1e56c89fb64893a861fb7 100644 --- a/employee-portal/src/app/(baseModule)/(static)/acknowledgements/page.tsx +++ b/employee-portal/src/app/(baseModule)/(static)/acknowledgements/page.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { Acknowledgements } from "@/lib/baseModule/components/acknowledgements/Acknowledgements"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function AcknowledgementsPage() { return ( diff --git a/employee-portal/src/app/(baseModule)/(static)/usage-notes/page.tsx b/employee-portal/src/app/(baseModule)/(static)/usage-notes/page.tsx index dc966d62d927dd714ccb35d347ad925d9bddf5ad..caed4bf68d93d52ee529f2e45e5f830e6cb61e3c 100644 --- a/employee-portal/src/app/(baseModule)/(static)/usage-notes/page.tsx +++ b/employee-portal/src/app/(baseModule)/(static)/usage-notes/page.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { UsageNotes } from "@/lib/baseModule/components/usage/UsageNotes"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function UsageNotesPage() { return ( diff --git a/employee-portal/src/app/(baseModule)/account/login-protocol/page.tsx b/employee-portal/src/app/(baseModule)/account/login-protocol/page.tsx index 243448c03201715df64431bfb5fcf61eea0471c8..58ab882cfb20f9204fa43987815404c6523a4a48 100644 --- a/employee-portal/src/app/(baseModule)/account/login-protocol/page.tsx +++ b/employee-portal/src/app/(baseModule)/account/login-protocol/page.tsx @@ -6,6 +6,9 @@ "use client"; import { ApiUserEvent, ApiUserEventType } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { formatDateTime } from "@eshg/lib-portal/formatters/dateTime"; import ChevronLeft from "@mui/icons-material/ChevronLeft"; import ChevronRight from "@mui/icons-material/ChevronRight"; @@ -16,9 +19,6 @@ import { useState } from "react"; import { isNonNullish } from "remeda"; import { useGetSelfUserEvents } from "@/lib/baseModule/api/queries/users"; -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 { IconButton } from "@/lib/shared/components/pagination/IconButton"; import { RowsPerPageSelect } from "@/lib/shared/components/pagination/RowsPerPageSelect"; import { diff --git a/employee-portal/src/app/(baseModule)/account/sessions/page.tsx b/employee-portal/src/app/(baseModule)/account/sessions/page.tsx index ce98462c0ecdea4cf125cd6d82492788f85ba75d..5f43affdd26d1f73ae5bfc9cabb1f0a077fcc541 100644 --- a/employee-portal/src/app/(baseModule)/account/sessions/page.tsx +++ b/employee-portal/src/app/(baseModule)/account/sessions/page.tsx @@ -6,6 +6,9 @@ "use client"; import { ApiActiveUserSession } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { formatDateTime } from "@eshg/lib-portal/formatters/dateTime"; import LaptopIcon from "@mui/icons-material/Laptop"; import LogoutIcon from "@mui/icons-material/Logout"; @@ -16,9 +19,6 @@ import { ReactNode, useMemo } from "react"; import { useInvalidateUserSessions } from "@/lib/baseModule/api/mutations/users"; import { useGetSelfActiveSessions } from "@/lib/baseModule/api/queries/users"; -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 { DataTable } from "@/lib/shared/components/table/DataTable"; import { TableSheet } from "@/lib/shared/components/table/TableSheet"; import { join } from "@/lib/shared/helpers/strings"; diff --git a/employee-portal/src/app/(baseModule)/auditlog/authorize/page.tsx b/employee-portal/src/app/(baseModule)/auditlog/authorize/page.tsx index 4189c8e4c637220a314bc26892e37a9528727211..42e6e258826827e49bd6e1ce33e5b6ef823b5fad 100644 --- a/employee-portal/src/app/(baseModule)/auditlog/authorize/page.tsx +++ b/employee-portal/src/app/(baseModule)/auditlog/authorize/page.tsx @@ -6,13 +6,13 @@ "use client"; import { ApiUserRole } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { SearchParams } from "@eshg/lib-portal/helpers/searchParams"; import { AuditLogAuthorizePage } from "@/lib/auditlog/components/authorize/AuditLogAuthorizePage"; import { RestrictedPage } from "@/lib/shared/components/RestrictedPage"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function AuditLogAuthorizeAccessPage( props: Readonly<{ diff --git a/employee-portal/src/app/(baseModule)/auditlog/page.tsx b/employee-portal/src/app/(baseModule)/auditlog/page.tsx index 26372b27177f7044d35fcbab75bdb806e88727bf..bf2a3e4130d9cfcc164aeadd373a50b33d222c05 100644 --- a/employee-portal/src/app/(baseModule)/auditlog/page.tsx +++ b/employee-portal/src/app/(baseModule)/auditlog/page.tsx @@ -6,6 +6,9 @@ "use client"; import { ApiUserRole } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { PortalError } from "@eshg/lib-portal/errorHandling/PortalError"; import { PortalErrorCode } from "@eshg/lib-portal/errorHandling/PortalErrorCode"; @@ -13,9 +16,6 @@ import { AuditlogAccessibleTableView } from "@/lib/auditlog/components/AuditlogA import { AuditlogCreatePasswordView } from "@/lib/auditlog/components/AuditlogCreatePasswordView"; import { useGetEmployeePrivateUserKey } from "@/lib/baseModule/api/queries/users"; import { RestrictedPage } from "@/lib/shared/components/RestrictedPage"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function AuditlogPage() { return ( diff --git a/employee-portal/src/app/(baseModule)/calendar/page.tsx b/employee-portal/src/app/(baseModule)/calendar/page.tsx index 26f32f28418f26f5f3a6b7fde3ab77ce8d6ba5a5..ce34fe34a58686f96198dc3ed65a66008f8cdcee 100644 --- a/employee-portal/src/app/(baseModule)/calendar/page.tsx +++ b/employee-portal/src/app/(baseModule)/calendar/page.tsx @@ -5,11 +5,12 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { useGetRelevantCalendarsForCurrentUser } from "@/lib/baseModule/api/queries/calendar"; import { UserCalendar } from "@/lib/baseModule/components/calendar/UserCalendar"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function CalendarPage() { const { data: relevantCalendarsResponse } = diff --git a/employee-portal/src/app/(baseModule)/contacts/[id]/page.tsx b/employee-portal/src/app/(baseModule)/contacts/[id]/page.tsx index e9843c71bb700e337e147417c1ab0e28730cc33a..b595db585cd05106222df14e885bb4ba19a4e7cf 100644 --- a/employee-portal/src/app/(baseModule)/contacts/[id]/page.tsx +++ b/employee-portal/src/app/(baseModule)/contacts/[id]/page.tsx @@ -5,6 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { Box, Grid, Typography } from "@mui/joy"; import { @@ -16,9 +19,6 @@ import { fullContactName } from "@/lib/baseModule/components/contacts/helpers"; import { ContactHistory } from "@/lib/baseModule/components/contacts/history/ContactHistory"; import { routes } from "@/lib/baseModule/shared/routes"; import { ContentPanel } from "@/lib/shared/components/contentPanel/ContentPanel"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function ContactDetailsPage({ params, diff --git a/employee-portal/src/app/(baseModule)/contacts/page.tsx b/employee-portal/src/app/(baseModule)/contacts/page.tsx index 16722fd444120b268d3a2d300287aaffd8435516..b590294ef2ff13c0270f95951549389daaccf9c4 100644 --- a/employee-portal/src/app/(baseModule)/contacts/page.tsx +++ b/employee-portal/src/app/(baseModule)/contacts/page.tsx @@ -11,6 +11,9 @@ import { ApiContactType, ApiSortDirection, } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { parseOptionalEnum, parseOptionalString, @@ -23,9 +26,6 @@ import { ContactsOverview, } from "@/lib/baseModule/components/contacts/ContactsOverview"; import { contactSearchParamNames } from "@/lib/baseModule/components/contacts/constants"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; function parseSearchParams( searchParams: ReadonlyURLSearchParams, diff --git a/employee-portal/src/app/(baseModule)/gdpr/[id]/page.tsx b/employee-portal/src/app/(baseModule)/gdpr/[id]/page.tsx index 1a7c9d99554767304d73284dd50a19e4fca4c84a..56702b7e7904f7fdb8df374cd64a9f335ec91368 100644 --- a/employee-portal/src/app/(baseModule)/gdpr/[id]/page.tsx +++ b/employee-portal/src/app/(baseModule)/gdpr/[id]/page.tsx @@ -5,12 +5,13 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { useGetGdprProcedureDetailsPageQuery } from "@/lib/baseModule/api/queries/gdpr"; import { GDPRProcedureDetails } from "@/lib/baseModule/components/gdpr/procedure/GDPRProcedureDetails"; 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"; export default function GDPRProcedurePage({ params, diff --git a/employee-portal/src/app/(baseModule)/gdpr/page.tsx b/employee-portal/src/app/(baseModule)/gdpr/page.tsx index 75e702a7a1cb3a1e77bcd910622def4f7152c347..73d3a2e544785de8be7460dd3cf701308212e23d 100644 --- a/employee-portal/src/app/(baseModule)/gdpr/page.tsx +++ b/employee-portal/src/app/(baseModule)/gdpr/page.tsx @@ -11,6 +11,9 @@ import { ApiSortDirection, GetGdprProceduresRequest, } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { parseOptionalEnum, parseReadonlyPageParams, @@ -18,9 +21,6 @@ import { import { ReadonlyURLSearchParams, useSearchParams } from "next/navigation"; import { GDPRTable } from "@/lib/baseModule/components/gdpr/overview/GDPRTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; function parseSearchParams( searchParams: ReadonlyURLSearchParams, diff --git a/employee-portal/src/app/(baseModule)/gdpr/validation-tasks/[businessModule]/[gdprProcedureId]/page.tsx b/employee-portal/src/app/(baseModule)/gdpr/validation-tasks/[businessModule]/[gdprProcedureId]/page.tsx index 3fb7d16407067cf7189e002dc1bd818a54c5e227..88c35e2bb3839cb1c5f233618ca0765720420ae4 100644 --- a/employee-portal/src/app/(baseModule)/gdpr/validation-tasks/[businessModule]/[gdprProcedureId]/page.tsx +++ b/employee-portal/src/app/(baseModule)/gdpr/validation-tasks/[businessModule]/[gdprProcedureId]/page.tsx @@ -5,6 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { useSuspenseQuery } from "@tanstack/react-query"; import { formatIdentityName } from "@/lib/baseModule/components/gdpr/helpers"; @@ -12,9 +15,6 @@ import { ValidationTaskProceduresTable } from "@/lib/baseModule/components/gdpr/ import { routes } from "@/lib/baseModule/shared/routes"; import { useGdprValidationTaskApi } from "@/lib/shared/api/clients"; import { getGdprValidationTaskDetailsQuery } from "@/lib/shared/api/queries/gdpr"; -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 { isBusinessModule } from "@/lib/shared/helpers/guards"; export default function GdprValidationTaskPage({ diff --git a/employee-portal/src/app/(baseModule)/gdpr/validation-tasks/[businessModule]/overview/page.tsx b/employee-portal/src/app/(baseModule)/gdpr/validation-tasks/[businessModule]/overview/page.tsx index f96fbb7c2b37e68f8aa53716a49e55393ff3ccf0..cda395698c958e0384ca40c23596262ce250abe3 100644 --- a/employee-portal/src/app/(baseModule)/gdpr/validation-tasks/[businessModule]/overview/page.tsx +++ b/employee-portal/src/app/(baseModule)/gdpr/validation-tasks/[businessModule]/overview/page.tsx @@ -5,6 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { parseOptionalEnum, parseReadonlyPageParams, @@ -17,9 +20,6 @@ import { import { ReadonlyURLSearchParams, useSearchParams } from "next/navigation"; import { ValidationTasksTable } from "@/lib/baseModule/components/gdpr/validationTasks/ValidationTasksTable"; -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 { isBusinessModule } from "@/lib/shared/helpers/guards"; function parseSearchParams( diff --git a/employee-portal/src/app/(baseModule)/inbox-procedures/page.tsx b/employee-portal/src/app/(baseModule)/inbox-procedures/page.tsx index 1c67c4c5c007642e2f4af2b14b8a69879a4741eb..7043da96820ca41d1d04591449b51b885109762e 100644 --- a/employee-portal/src/app/(baseModule)/inbox-procedures/page.tsx +++ b/employee-portal/src/app/(baseModule)/inbox-procedures/page.tsx @@ -6,6 +6,9 @@ "use client"; import { ApiUserRole } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { ApiInboxProcedure } from "@eshg/lib-procedures-api"; import { useState } from "react"; @@ -23,9 +26,6 @@ import { } from "@/lib/baseModule/components/inboxProcedures/mapper"; import { InboxAwareBusinessModule } from "@/lib/baseModule/components/inboxProcedures/types"; import { RestrictedPage } from "@/lib/shared/components/RestrictedPage"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; const initialValues: CreateInboxProcedureValues = { businessModule: "", diff --git a/employee-portal/src/app/(baseModule)/inventory/[id]/page.tsx b/employee-portal/src/app/(baseModule)/inventory/[id]/page.tsx index 776c5f1b2de12a88bd215966e6b34f58642cab9b..6ed6b50a2542fcbf525792efdf26bd56a0af1eaa 100644 --- a/employee-portal/src/app/(baseModule)/inventory/[id]/page.tsx +++ b/employee-portal/src/app/(baseModule)/inventory/[id]/page.tsx @@ -6,6 +6,9 @@ "use client"; import { ApiUserRole } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import AddIcon from "@mui/icons-material/Add"; import { Button, Stack } from "@mui/joy"; import { useState } from "react"; @@ -15,9 +18,6 @@ import { InventoryBooking } from "@/lib/baseModule/components/inventory/Inventor import { InventoryDetails } from "@/lib/baseModule/components/inventory/InventoryDetails"; import { useInventoryRestockSidebar } from "@/lib/baseModule/components/inventory/modals/InventoryRestockSidebar"; 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 { useHasUserRoleCheck } from "@/lib/shared/hooks/useAccessControl"; export default function InventoryDetailsPage({ diff --git a/employee-portal/src/app/(baseModule)/inventory/page.tsx b/employee-portal/src/app/(baseModule)/inventory/page.tsx index 9f4620980cb056eb4e7ab2c5f569c9eaa0279c02..317c92a0e9747b2188a1bf2d56fccc81bdbc8141 100644 --- a/employee-portal/src/app/(baseModule)/inventory/page.tsx +++ b/employee-portal/src/app/(baseModule)/inventory/page.tsx @@ -11,6 +11,9 @@ import { ApiSortDirection, GetInventoryItemsRequest, } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { parseOptionalEnum, parseOptionalString, @@ -19,9 +22,6 @@ import { import { ReadonlyURLSearchParams, useSearchParams } from "next/navigation"; import { InventoryTable } from "@/lib/baseModule/components/inventory/InventoryTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; function parseSearchParams( searchParams: ReadonlyURLSearchParams, 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 1ec742b76c2b9f83b9a3e786fec808239fa5662f..1de3460b32796a35621a8896e900320ca45a344e 100644 --- a/employee-portal/src/app/(baseModule)/metrics/[businessModuleName]/[procedureType]/page.tsx +++ b/employee-portal/src/app/(baseModule)/metrics/[businessModuleName]/[procedureType]/page.tsx @@ -6,12 +6,12 @@ "use client"; import { ApiBusinessModule, ApiProcedureType } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; 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( diff --git a/employee-portal/src/app/(baseModule)/metrics/page.tsx b/employee-portal/src/app/(baseModule)/metrics/page.tsx index 07dc79fe04025fc268eea0d254067313ecbfc19e..c4fe8d6d5e6a8e4c9c84357b070833dad0d36c7d 100644 --- a/employee-portal/src/app/(baseModule)/metrics/page.tsx +++ b/employee-portal/src/app/(baseModule)/metrics/page.tsx @@ -5,10 +5,11 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { ProcedureMetricsDisplay } from "@/lib/baseModule/components/procedureMetrics/ProcedureMetricsDisplay"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function ProcedureMetricsPage() { return ( diff --git a/employee-portal/src/app/(baseModule)/opendata/page.tsx b/employee-portal/src/app/(baseModule)/opendata/page.tsx index 14e1bd8776786875fb17961f4c9da4e7996d85fe..55c8b3d113ad1c7b8136e1c00a193c6960843352 100644 --- a/employee-portal/src/app/(baseModule)/opendata/page.tsx +++ b/employee-portal/src/app/(baseModule)/opendata/page.tsx @@ -6,12 +6,12 @@ "use client"; import { ApiBaseFeature } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { OpenDataTable } from "@/lib/opendata/components/OpenDataTable"; import { ToggledPage } from "@/lib/shared/components/ToggledPage"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function OpenDataPage() { return ( diff --git a/employee-portal/src/app/(baseModule)/page.tsx b/employee-portal/src/app/(baseModule)/page.tsx index 3844ddf837d7e4a3dd7baba1d35ed0f78ef09449..a073b7a14daef30b56e9961f93bf392e38f13c59 100644 --- a/employee-portal/src/app/(baseModule)/page.tsx +++ b/employee-portal/src/app/(baseModule)/page.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { Dashboard } from "@/lib/baseModule/components/dashboard/Dashboard"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function DashboardPage() { return ( diff --git a/employee-portal/src/app/(baseModule)/resources/[id]/page.tsx b/employee-portal/src/app/(baseModule)/resources/[id]/page.tsx index a025c4ae4e86e74caa23394772af9bf3a84a9f78..d1c05f54b40bceb412d643cd90d1d359ccd6b2fc 100644 --- a/employee-portal/src/app/(baseModule)/resources/[id]/page.tsx +++ b/employee-portal/src/app/(baseModule)/resources/[id]/page.tsx @@ -5,15 +5,15 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { endOfMonth, startOfMonth } from "date-fns"; import { startTransition, useState } from "react"; import { useGetResourceDetailsQuery } from "@/lib/baseModule/api/queries/resources"; import { ResourceDetail } from "@/lib/baseModule/components/resources/ResourceDetail"; 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"; export default function ResourceDetailsPage({ params, diff --git a/employee-portal/src/app/(baseModule)/resources/page.tsx b/employee-portal/src/app/(baseModule)/resources/page.tsx index c9205a57599b97154d2c35c816176a5431b7bb0c..20219f22a89b2d14fc9848ba699f5791c54cf02d 100644 --- a/employee-portal/src/app/(baseModule)/resources/page.tsx +++ b/employee-portal/src/app/(baseModule)/resources/page.tsx @@ -11,6 +11,9 @@ import { ApiSortDirection, GetResourcesRequest, } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { parseOptionalEnum, parseOptionalString, @@ -19,9 +22,6 @@ import { import { ReadonlyURLSearchParams, useSearchParams } from "next/navigation"; import { ResourcesTable } from "@/lib/baseModule/components/resources/ResourcesTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; function parseSearchParams( searchParams: ReadonlyURLSearchParams, diff --git a/employee-portal/src/app/(baseModule)/tasks/page.tsx b/employee-portal/src/app/(baseModule)/tasks/page.tsx index 94926a069ab8f6512ff9e60c33b558fd98bc5ba5..7316a357b09e114d13d9219523cb140624a8121b 100644 --- a/employee-portal/src/app/(baseModule)/tasks/page.tsx +++ b/employee-portal/src/app/(baseModule)/tasks/page.tsx @@ -4,13 +4,13 @@ */ import { ApiUserRole } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { SearchParams } from "@eshg/lib-portal/helpers/searchParams"; import { TasksTable } from "@/lib/baseModule/components/task/TasksTable"; import { RestrictedPage } from "@/lib/shared/components/RestrictedPage"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function TasksPage( props: Readonly<{ diff --git a/employee-portal/src/app/(baseModule)/users/[id]/page.tsx b/employee-portal/src/app/(baseModule)/users/[id]/page.tsx index 7e140d5ff90965fb12d0b450e68266961c61a7af..6967a97eccc07cb4f18c3ffcb207675a46775aeb 100644 --- a/employee-portal/src/app/(baseModule)/users/[id]/page.tsx +++ b/employee-portal/src/app/(baseModule)/users/[id]/page.tsx @@ -5,15 +5,15 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { Stack } from "@mui/joy"; import { useGetUserProfile } from "@/lib/baseModule/api/queries/users"; import { UserAbsence } from "@/lib/baseModule/components/users/UserAbsence"; import { UserProfileDetails } from "@/lib/baseModule/components/users/UserProfileDetails"; 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 { fullName } from "@/lib/shared/components/users/userFormatter"; export default function UserProfilePage({ diff --git a/employee-portal/src/app/(baseModule)/users/page.tsx b/employee-portal/src/app/(baseModule)/users/page.tsx index f155833545bbf924cd9bf09a7dc57ccc17fcbec1..7dcb124f1e84c4d74449955f213f7ecdb6e2659d 100644 --- a/employee-portal/src/app/(baseModule)/users/page.tsx +++ b/employee-portal/src/app/(baseModule)/users/page.tsx @@ -5,10 +5,11 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { UserTable } from "@/lib/baseModule/components/users/UserTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function UserOverviewPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/chat/layout.tsx b/employee-portal/src/app/(businessModules)/chat/layout.tsx index 0fc7748e8a0ac205ea971147752978f9d74016f8..033defc09f74e7e616c300f9c832504d1f59f8ed 100644 --- a/employee-portal/src/app/(businessModules)/chat/layout.tsx +++ b/employee-portal/src/app/(businessModules)/chat/layout.tsx @@ -3,12 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { PropsWithChildren } from "react"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; - export default function ChatLayout({ children }: PropsWithChildren) { return ( <StickyToolbarLayout toolbar={<Toolbar title="Chat" />}> diff --git a/employee-portal/src/app/(businessModules)/dental/children/[childId]/examinations/[examinationId]/page.tsx b/employee-portal/src/app/(businessModules)/dental/children/[childId]/examinations/[examinationId]/page.tsx index b791d0bab9739e7fa67839d9c64e99053f94fb19..310927e80c4d390e037047a44fe2c4a010758aa7 100644 --- a/employee-portal/src/app/(businessModules)/dental/children/[childId]/examinations/[examinationId]/page.tsx +++ b/employee-portal/src/app/(businessModules)/dental/children/[childId]/examinations/[examinationId]/page.tsx @@ -5,13 +5,17 @@ "use client"; -import { getExaminationQuery } from "@eshg/dental/api/queries/childApi"; +import { + getChildDetailsQuery, + getExaminationQuery, +} from "@eshg/dental/api/queries/childApi"; import { useDentalApi } from "@eshg/dental/shared/DentalProvider"; -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useSuspenseQueries } from "@tanstack/react-query"; import { DentalChildPageProps } from "@/app/(businessModules)/dental/children/[childId]/layout"; import { ChildExaminationForm } from "@/lib/businessModules/dental/features/children/details/ChildExaminationForm"; import { AdditionalInformationFormSection } from "@/lib/businessModules/dental/features/examinations/AdditionalInformationFormSection"; +import { ChildDetailsSection } from "@/lib/businessModules/dental/features/examinations/ChildDetailsSection"; import { ExaminationFormLayout } from "@/lib/businessModules/dental/features/examinations/ExaminationFormLayout"; import { NoteFormSection } from "@/lib/businessModules/dental/features/examinations/NoteFormSection"; import { DentalExaminationFormSection } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/DentalExaminationFormSection"; @@ -20,14 +24,31 @@ import { DentalExaminationStoreProvider } from "@/lib/businessModules/dental/fea export default function ExaminationDetailsPage(props: DentalChildPageProps) { const { childApi } = useDentalApi(); const examinationId = props.params.examinationId; - const { data: examination } = useSuspenseQuery( - getExaminationQuery(childApi, examinationId), + const childId = props.params.childId; + const [{ data: examination }, { data: child }] = useSuspenseQueries({ + queries: [ + getExaminationQuery(childApi, examinationId), + getChildDetailsQuery(childApi, childId), + ], + }); + const institutionAtExaminationDate = child.institutions.find( + (institution) => institution.year === examination.dateAndTime.getFullYear(), ); return ( <DentalExaminationStoreProvider examinationResult={examination.result}> <ChildExaminationForm examination={examination}> <ExaminationFormLayout + childInformation={ + <ChildDetailsSection + firstName={child.firstName} + lastName={child.lastName} + dateOfBirth={child.dateOfBirth} + dateOfExamination={examination.dateAndTime} + groupName={institutionAtExaminationDate?.groupName ?? ""} + allFluoridationConsents={child.allFluoridationConsents} + /> + } additionalInformation={ <AdditionalInformationFormSection screening={examination.screening} diff --git a/employee-portal/src/app/(businessModules)/dental/children/[childId]/layout.tsx b/employee-portal/src/app/(businessModules)/dental/children/[childId]/layout.tsx index 9bcf923156386f81e1d2f3e4a312ffe51ec82307..17f191c5477538f0efdb8aaa2545f4bc22d998cc 100644 --- a/employee-portal/src/app/(businessModules)/dental/children/[childId]/layout.tsx +++ b/employee-portal/src/app/(businessModules)/dental/children/[childId]/layout.tsx @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; import { PropsWithChildren } from "react"; import { ChildToolbar } from "@/lib/businessModules/dental/features/children/details/ChildToolbar"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; export type DentalChildPageProps = Readonly<{ params: DentalChildPageParams; diff --git a/employee-portal/src/app/(businessModules)/dental/children/page.tsx b/employee-portal/src/app/(businessModules)/dental/children/page.tsx index 419436a9648c13821e7861e8a07691bd57d83dbe..5c5b95e4c06588f518f4bd9709b346aeab88977d 100644 --- a/employee-portal/src/app/(businessModules)/dental/children/page.tsx +++ b/employee-portal/src/app/(businessModules)/dental/children/page.tsx @@ -5,6 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { Cached } from "@mui/icons-material"; import { Button } from "@mui/joy"; @@ -13,10 +16,6 @@ import { CloseSchoolYearButton } from "@/lib/businessModules/dental/features/chi import { CreateChildSidebar } from "@/lib/businessModules/dental/features/children/new/CreateChildSidebar"; import { useImportChildrenSidebar } from "@/lib/businessModules/dental/import/ImportChildrenSidebar"; import { BUTTON_SIZE } from "@/lib/businessModules/schoolEntry/features/procedures/new/constants"; -import { OverlayBoundary } from "@/lib/shared/components/boundaries/OverlayBoundary"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; function ImportChildrenButton() { const importChildrenSidebar = useImportChildrenSidebar(); @@ -32,14 +31,6 @@ function ImportChildrenButton() { ); } -function CreateChildButton() { - return ( - <OverlayBoundary> - <CreateChildSidebar /> - </OverlayBoundary> - ); -} - export default function DentalProceduresPage() { return ( <StickyToolbarLayout toolbar={<Toolbar title="Zahnärztlicher Dienst" />}> @@ -48,7 +39,7 @@ export default function DentalProceduresPage() { buttons={[ <CloseSchoolYearButton key="closeSchoolYear" />, <ImportChildrenButton key="importChildren" />, - <CreateChildButton key="createChild" />, + <CreateChildSidebar key="createChild" />, ]} /> </MainContentLayout> diff --git a/employee-portal/src/app/(businessModules)/dental/prophylaxis-sessions/[prophylaxisSessionId]/details/page.tsx b/employee-portal/src/app/(businessModules)/dental/prophylaxis-sessions/[prophylaxisSessionId]/details/page.tsx index de8cd6c227c27603e58d7610a4735408f9193fc0..18351231cab6489f41131ac468ae930ddf52ab9d 100644 --- a/employee-portal/src/app/(businessModules)/dental/prophylaxis-sessions/[prophylaxisSessionId]/details/page.tsx +++ b/employee-portal/src/app/(businessModules)/dental/prophylaxis-sessions/[prophylaxisSessionId]/details/page.tsx @@ -6,13 +6,13 @@ "use client"; import { routes } from "@eshg/dental/shared/routes"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { ProphylaxisSessionDetails } from "@/lib/businessModules/dental/features/prophylaxisSessions/ProphylaxisSessionDetails"; import { useProphylaxisSessionStore } from "@/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/ProphylaxisSessionStoreProvider"; import { useSyncOutgoingProphylaxisSessionChanges } from "@/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/useSyncOutgoingProphylaxisSessionChanges"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function ProphylaxisSessionDetailsPage() { const institutionName = useProphylaxisSessionStore( diff --git a/employee-portal/src/app/(businessModules)/dental/prophylaxis-sessions/[prophylaxisSessionId]/error.tsx b/employee-portal/src/app/(businessModules)/dental/prophylaxis-sessions/[prophylaxisSessionId]/error.tsx index 4cffb7e3cbed7eaeb73bd52b644413a700786c0e..4a7d67b8f31a4f78283da402de3a940332e3822d 100644 --- a/employee-portal/src/app/(businessModules)/dental/prophylaxis-sessions/[prophylaxisSessionId]/error.tsx +++ b/employee-portal/src/app/(businessModules)/dental/prophylaxis-sessions/[prophylaxisSessionId]/error.tsx @@ -5,13 +5,12 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { NextErrorBoundary, NextErrorBoundaryProps, } from "@eshg/lib-portal/components/boundaries/NextErrorBoundary"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; - export default function ProphylaxisSessionError(props: NextErrorBoundaryProps) { return ( <MainContentLayout fullViewportHeight> diff --git a/employee-portal/src/app/(businessModules)/dental/prophylaxis-sessions/page.tsx b/employee-portal/src/app/(businessModules)/dental/prophylaxis-sessions/page.tsx index b0e7634bc559149f61016cb9a73465024b71b143..f6d4bebb4dd0e07d597f1fd75b7d24447ed689e0 100644 --- a/employee-portal/src/app/(businessModules)/dental/prophylaxis-sessions/page.tsx +++ b/employee-portal/src/app/(businessModules)/dental/prophylaxis-sessions/page.tsx @@ -5,14 +5,14 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { Add } from "@mui/icons-material"; import { Button } from "@mui/joy"; import { useCreateProphylaxisSessionSidebar } from "@/lib/businessModules/dental/features/prophylaxisSessions/CreateProphylaxisSessionSidebar"; import { ProphylaxisSessionsTable } from "@/lib/businessModules/dental/features/prophylaxisSessions/ProphylaxisSessionsTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; function CreateProphylaxisSessionButton() { const createProphylaxisSessionSidebar = useCreateProphylaxisSessionSidebar(); diff --git a/employee-portal/src/app/(businessModules)/inspection/checklist/def/[defId]/versions/[versionId]/new/page.tsx b/employee-portal/src/app/(businessModules)/inspection/checklist/def/[defId]/versions/[versionId]/new/page.tsx index dc24a860f77e45149e70ebfd5ecf0654d312a440..5be04218d783ff753bdd410fdd755acdc57278d3 100644 --- a/employee-portal/src/app/(businessModules)/inspection/checklist/def/[defId]/versions/[versionId]/new/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/checklist/def/[defId]/versions/[versionId]/new/page.tsx @@ -5,6 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { useSuspenseQueries } from "@tanstack/react-query"; import { @@ -15,9 +18,6 @@ import { getChecklistDefinitionVersionQuery } from "@/lib/businessModules/inspec import { getObjectTypesQuery } from "@/lib/businessModules/inspection/api/queries/objectTypes"; import { EditChecklistDefinition } from "@/lib/businessModules/inspection/components/checklistDefinition/editor/EditChecklistDefinition"; import { routes } from "@/lib/businessModules/inspection/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"; export default function NewChecklistVersion({ params: { defId, versionId }, diff --git a/employee-portal/src/app/(businessModules)/inspection/checklist/def/[defId]/versions/[versionId]/page.tsx b/employee-portal/src/app/(businessModules)/inspection/checklist/def/[defId]/versions/[versionId]/page.tsx index ac98bf8041076ab44d637d81524c7483f23fa1fc..6ecff0619876af7bd753b80bb852754d734b14cb 100644 --- a/employee-portal/src/app/(businessModules)/inspection/checklist/def/[defId]/versions/[versionId]/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/checklist/def/[defId]/versions/[versionId]/page.tsx @@ -5,12 +5,13 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { useGetChecklistDefinitionVersion } from "@/lib/businessModules/inspection/api/queries/checklistDefinition"; import { ReadOnlyCLDPage } from "@/lib/businessModules/inspection/components/checklistDefinition/readOnly/ReadOnlyCLDPage"; import { routes } from "@/lib/businessModules/inspection/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"; export default function ViewChecklistVersion({ params: { defId, versionId }, diff --git a/employee-portal/src/app/(businessModules)/inspection/checklist/def/new/page.tsx b/employee-portal/src/app/(businessModules)/inspection/checklist/def/new/page.tsx index a6509f7f81027f61b3af696329a2a91827763c3b..df4ce3f192f21841481b31d3080e73755230d835 100644 --- a/employee-portal/src/app/(businessModules)/inspection/checklist/def/new/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/checklist/def/new/page.tsx @@ -5,12 +5,13 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { useGetObjectTypes } from "@/lib/businessModules/inspection/api/queries/objectTypes"; import { EditChecklistDefinition } from "@/lib/businessModules/inspection/components/checklistDefinition/editor/EditChecklistDefinition"; import { routes } from "@/lib/businessModules/inspection/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"; export default function NewChecklist() { const { data: objectTypes } = useGetObjectTypes(); diff --git a/employee-portal/src/app/(businessModules)/inspection/checklist/def/page.tsx b/employee-portal/src/app/(businessModules)/inspection/checklist/def/page.tsx index 66f281d6bbf6dcadef7d504bebab74b362f4e832..5deeed1690a4ff0a16cbea7d98fc9ec95cd6c7a9 100644 --- a/employee-portal/src/app/(businessModules)/inspection/checklist/def/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/checklist/def/page.tsx @@ -6,6 +6,9 @@ "use client"; import { ApiUserRole } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { InternalLinkButton } from "@eshg/lib-portal/components/navigation/InternalLinkButton"; import AddIcon from "@mui/icons-material/Add"; import { Box } from "@mui/joy"; @@ -13,9 +16,6 @@ import { Box } from "@mui/joy"; import { useGetChecklistDefinitions } from "@/lib/businessModules/inspection/api/queries/checklistDefinition"; import { ChecklistDefinitionOverviewTable } from "@/lib/businessModules/inspection/components/checklistDefinition/overview/ChecklistDefinitionOverviewTable"; import { routes } from "@/lib/businessModules/inspection/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 { useHasUserRoleCheck } from "@/lib/shared/hooks/useAccessControl"; export default function ChecklistOverview() { diff --git a/employee-portal/src/app/(businessModules)/inspection/facility/search/[id]/page.tsx b/employee-portal/src/app/(businessModules)/inspection/facility/search/[id]/page.tsx index f9b487800e93b2e745b9c9c4718feca5dada6eae..09f56a418a001a41e89454429ccef999e3ca803c 100644 --- a/employee-portal/src/app/(businessModules)/inspection/facility/search/[id]/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/facility/search/[id]/page.tsx @@ -5,12 +5,13 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { useGetWebSearchById } from "@/lib/businessModules/inspection/api/queries/webSearch"; import { FacilityWebSearchForm } from "@/lib/businessModules/inspection/components/facility/search/FacilityWebSearchForm"; import { routes } from "@/lib/businessModules/inspection/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"; type EditFacilityPageProps = Readonly<{ params: { id: string }; diff --git a/employee-portal/src/app/(businessModules)/inspection/facility/search/[id]/results/page.tsx b/employee-portal/src/app/(businessModules)/inspection/facility/search/[id]/results/page.tsx index 0cae7f523b7efb6cbe5ed8f984e7bbe8e3333ddc..65342109bddee9cca56c857522ffbbb2a0a3c2e3 100644 --- a/employee-portal/src/app/(businessModules)/inspection/facility/search/[id]/results/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/facility/search/[id]/results/page.tsx @@ -5,6 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { SearchParams } from "@eshg/lib-portal/helpers/searchParams"; import { @@ -14,9 +17,6 @@ import { import { FacilityWebSearchResultsTable } from "@/lib/businessModules/inspection/components/facility/search/results/FacilityWebSearchResultsTable"; import { routes } from "@/lib/businessModules/inspection/shared/routes"; import { FacilityWebSearchFilters } from "@/lib/businessModules/inspection/shared/types"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; type EditFacilityPageProps = Readonly<{ params: { id: string }; diff --git a/employee-portal/src/app/(businessModules)/inspection/facility/search/new/page.tsx b/employee-portal/src/app/(businessModules)/inspection/facility/search/new/page.tsx index a48d05367487d6dce8dae9061d72c98313a10bfb..ea08628d852b792d2de2e1893a944edc57b362aa 100644 --- a/employee-portal/src/app/(businessModules)/inspection/facility/search/new/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/facility/search/new/page.tsx @@ -3,14 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { FacilityWebSearchForm, WebSearch, } from "@/lib/businessModules/inspection/components/facility/search/FacilityWebSearchForm"; import { routes } from "@/lib/businessModules/inspection/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"; export default function NewFacilityWebSearchPage() { const initialValues: WebSearch = { diff --git a/employee-portal/src/app/(businessModules)/inspection/facility/search/page.tsx b/employee-portal/src/app/(businessModules)/inspection/facility/search/page.tsx index 89bea8efaa5f287fa75093d8312b3f3808277f9f..77b68922e64981a0ad539ac1f10c9b3248533502 100644 --- a/employee-portal/src/app/(businessModules)/inspection/facility/search/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/facility/search/page.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { FacilitiesWebSearchPageContent } from "@/lib/businessModules/inspection/components/facility/search/FacilityWebSearchPageContent"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function FacilitiesWebSearchPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/inspection/objecttype/page.tsx b/employee-portal/src/app/(businessModules)/inspection/objecttype/page.tsx index ffbefa48de251bd13252560e6cb4043deb81062e..5eab052f21981006de695fee5573dc24a9cd5aa0 100644 --- a/employee-portal/src/app/(businessModules)/inspection/objecttype/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/objecttype/page.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { ObjectTypesTable } from "@/lib/businessModules/inspection/components/objectType/ObjectTypesTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function ObjectTypePage() { return ( diff --git a/employee-portal/src/app/(businessModules)/inspection/packlist/def/page.tsx b/employee-portal/src/app/(businessModules)/inspection/packlist/def/page.tsx index e486a4830187a03407ae6f1ca0989b05ecc82c4d..91bff936c464f771d5065a4d507bca579e9dc342 100644 --- a/employee-portal/src/app/(businessModules)/inspection/packlist/def/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/packlist/def/page.tsx @@ -5,10 +5,11 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { PacklistDefinitionOverviewTable } from "@/lib/businessModules/inspection/components/packlistDefinition/PacklistDefinitionOverviewTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function PacklistOverview() { return ( diff --git a/employee-portal/src/app/(businessModules)/inspection/procedures/(subpages)/[id]/reportresult/edit/[reportId]/layout.tsx b/employee-portal/src/app/(businessModules)/inspection/procedures/(subpages)/[id]/reportresult/edit/[reportId]/layout.tsx index 6a8da6e70f18faac6194f8d38ae7757608ba6c20..577d2e96881a60ee11700c78ec1e0f2e072850fb 100644 --- a/employee-portal/src/app/(businessModules)/inspection/procedures/(subpages)/[id]/reportresult/edit/[reportId]/layout.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/procedures/(subpages)/[id]/reportresult/edit/[reportId]/layout.tsx @@ -5,12 +5,12 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; import { PropsWithChildren } from "react"; import { EditInspectionPageParams } from "@/app/(businessModules)/inspection/procedures/[id]/layout"; import { routes } from "@/lib/businessModules/inspection/shared/routes"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; import { SubPageHeader } from "@/lib/shared/components/page/SubPageHeader"; export default function InspectionReportEditorPageLayout({ diff --git a/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/basedata/layout.tsx b/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/basedata/layout.tsx index a85e81f6f70fa1354d8a2fd3910163a115148a8a..edeaf60014225b48fb8ee847035a323dceaa9a36 100644 --- a/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/basedata/layout.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/basedata/layout.tsx @@ -3,10 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { RequiresChildren } from "@eshg/lib-portal/types/react"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; - export default function BaseDataLayout({ children, }: Readonly<RequiresChildren>) { diff --git a/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/execution/layout.tsx b/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/execution/layout.tsx index 82906a7640219a5b93639df55dcc65edd8cc5a06..772ff9a55fcff90a5c58ad5662febf4d91cea268 100644 --- a/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/execution/layout.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/execution/layout.tsx @@ -5,10 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { RequiresChildren } from "@eshg/lib-portal/types/react"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; - export default function ExecutionLayout({ children, }: Readonly<RequiresChildren>) { diff --git a/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/layout.tsx b/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/layout.tsx index 7479f8809eb00734d40845825fe7845562f3de8c..0c85c57061d79030df9edf2420aafc7fb573c981 100644 --- a/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/layout.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/layout.tsx @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; import { PropsWithChildren } from "react"; import { InspectionTabNavigationToolbar } from "@/lib/businessModules/inspection/components/inspection/InspectionTabNavigationToolbar"; import { TrackInspectionView } from "@/lib/businessModules/inspection/components/inspection/TrackInspectionView"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; export interface EditInspectionPageParams { id: string; diff --git a/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/planning/layout.tsx b/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/planning/layout.tsx index 5b8891be6ab4aa3e9160541ddc668d62a22d88a4..9c379f8da62013b303f43860d7b2b5f5504640a9 100644 --- a/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/planning/layout.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/planning/layout.tsx @@ -3,10 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { RequiresChildren } from "@eshg/lib-portal/types/react"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; - export default function PlanningLayout({ children, }: Readonly<RequiresChildren>) { diff --git a/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/progress-entries/layout.tsx b/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/progress-entries/layout.tsx index 5b8891be6ab4aa3e9160541ddc668d62a22d88a4..9c379f8da62013b303f43860d7b2b5f5504640a9 100644 --- a/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/progress-entries/layout.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/progress-entries/layout.tsx @@ -3,10 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { RequiresChildren } from "@eshg/lib-portal/types/react"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; - export default function PlanningLayout({ children, }: Readonly<RequiresChildren>) { diff --git a/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/reportresult/layout.tsx b/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/reportresult/layout.tsx index 8633b0cdc72c478e68f852255d15fe46a643f6be..50bee7b2e2a43d54a5ab4f241d9d35262f037c52 100644 --- a/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/reportresult/layout.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/procedures/[id]/reportresult/layout.tsx @@ -3,10 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { RequiresChildren } from "@eshg/lib-portal/types/react"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; - export default function InspectionReportResultLayout({ children, }: Readonly<RequiresChildren>) { diff --git a/employee-portal/src/app/(businessModules)/inspection/procedures/new/[procedureId]/page.tsx b/employee-portal/src/app/(businessModules)/inspection/procedures/new/[procedureId]/page.tsx index c5358c474a02ac4e0cd209430201d38c2005d2fc..bcf157322422444b600cae7e1c807b42241ee283 100644 --- a/employee-portal/src/app/(businessModules)/inspection/procedures/new/[procedureId]/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/procedures/new/[procedureId]/page.tsx @@ -5,6 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { useSuspenseQueries } from "@tanstack/react-query"; import { useUserApi } from "@/lib/baseModule/api/clients"; @@ -20,9 +23,6 @@ import { } from "@/lib/businessModules/inspection/api/queries/users"; import { AddInspectionTiles } from "@/lib/businessModules/inspection/components/inspection/new/AddInspectionTiles"; import { routes } from "@/lib/businessModules/inspection/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"; export default function NewInspectionProcedurePage({ params, diff --git a/employee-portal/src/app/(businessModules)/inspection/procedures/page.tsx b/employee-portal/src/app/(businessModules)/inspection/procedures/page.tsx index 3b75ca4d7076018999dadc1222d57386feb786a5..205f6c983650971b1d202cb8202608e7c221fcef 100644 --- a/employee-portal/src/app/(businessModules)/inspection/procedures/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/procedures/page.tsx @@ -5,13 +5,13 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { SearchParams } from "@eshg/lib-portal/helpers/searchParams"; import { PendingFacilitiesOfflineTable } from "@/lib/businessModules/inspection/components/facility/pending/PendingFacilitiesOfflineTable"; import { PendingFacilitiesTableWrapper } from "@/lib/businessModules/inspection/components/facility/pending/PendingFacilitiesTableWrapper"; -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 { useIsOffline } from "@/lib/shared/hooks/useIsOffline"; export default function InspectionProceduresPage( diff --git a/employee-portal/src/app/(businessModules)/inspection/repository/checklist/[repositoryChecklistDefinitionId]/versions/[version]/page.tsx b/employee-portal/src/app/(businessModules)/inspection/repository/checklist/[repositoryChecklistDefinitionId]/versions/[version]/page.tsx index 481988baf161a1473255b322ec90c6dfe510d8b0..14fb704e16b7fb1bc60fcd1b4b9af52d1c883dfe 100644 --- a/employee-portal/src/app/(businessModules)/inspection/repository/checklist/[repositoryChecklistDefinitionId]/versions/[version]/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/repository/checklist/[repositoryChecklistDefinitionId]/versions/[version]/page.tsx @@ -5,13 +5,14 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { useGetChecklistDefinitionFromCentralRepo } from "@/lib/businessModules/inspection/api/queries/checklistDefinition"; import { ReadOnlyCLDPage } from "@/lib/businessModules/inspection/components/checklistDefinition/readOnly/ReadOnlyCLDPage"; import { RepoCLDInfoCard } from "@/lib/businessModules/inspection/components/repository/RepoCLDInfoCard"; import { routes } from "@/lib/businessModules/inspection/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"; export default function InspectionRepositoryPage({ params, diff --git a/employee-portal/src/app/(businessModules)/inspection/repository/core-checklist/[repositoryChecklistDefinitionId]/versions/[version]/page.tsx b/employee-portal/src/app/(businessModules)/inspection/repository/core-checklist/[repositoryChecklistDefinitionId]/versions/[version]/page.tsx index cfd42010b5678eeb54a8c4d160a60daff8062d1d..264b4da30688ef6dc8e8fb919f82ba3933f7deca 100644 --- a/employee-portal/src/app/(businessModules)/inspection/repository/core-checklist/[repositoryChecklistDefinitionId]/versions/[version]/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/repository/core-checklist/[repositoryChecklistDefinitionId]/versions/[version]/page.tsx @@ -5,13 +5,14 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { useGetChecklistDefinitionFromCentralRepo } from "@/lib/businessModules/inspection/api/queries/checklistDefinition"; import { ReadOnlyCLDPage } from "@/lib/businessModules/inspection/components/checklistDefinition/readOnly/ReadOnlyCLDPage"; import { RepoCLDInfoCard } from "@/lib/businessModules/inspection/components/repository/RepoCLDInfoCard"; import { routes } from "@/lib/businessModules/inspection/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"; export default function InspectionRepositoryPage({ params, diff --git a/employee-portal/src/app/(businessModules)/inspection/repository/page.tsx b/employee-portal/src/app/(businessModules)/inspection/repository/page.tsx index 40f67d8461d559d6913911963099b0332d437253..ee9d0079ce0d92a9e96abb6304a0562d7f884146 100644 --- a/employee-portal/src/app/(businessModules)/inspection/repository/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/repository/page.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { ChecklistDefinitionRepoOverviewTable } from "@/lib/businessModules/inspection/components/repository/ChecklistDefinitionRepoOverviewTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function InspectionRepositoryPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/inspection/teamview/page.tsx b/employee-portal/src/app/(businessModules)/inspection/teamview/page.tsx index 4e7665f2ed58b2c96ded01bb4e7b5c1aa24aecca..9254fd41b89c7285f3470dc3e486e608189eae5a 100644 --- a/employee-portal/src/app/(businessModules)/inspection/teamview/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/teamview/page.tsx @@ -6,13 +6,13 @@ "use client"; import { ApiBusinessModule, ApiUserRole } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { Teamview } from "@/lib/baseModule/components/task/Teamview"; import { moduleUserGroup } from "@/lib/businessModules/inspection/shared/moduleUserGroup"; import { RestrictedPage } from "@/lib/shared/components/RestrictedPage"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function InspectionTeamviewPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/inspection/textblocks/page.tsx b/employee-portal/src/app/(businessModules)/inspection/textblocks/page.tsx index 4f3c8b2dcfa6b51283dd1489ca4b394c5ce4acaa..31c9cae8799a21dbb321cb88ff3fbb17aa22ac14 100644 --- a/employee-portal/src/app/(businessModules)/inspection/textblocks/page.tsx +++ b/employee-portal/src/app/(businessModules)/inspection/textblocks/page.tsx @@ -6,13 +6,13 @@ "use client"; import { GetTextBlocksRequest } from "@eshg/inspection-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { SearchParams } from "@eshg/lib-portal/helpers/searchParams"; import { useGetTextBlocks } from "@/lib/businessModules/inspection/api/queries/textblocks"; import { TextBlocksTable } from "@/lib/businessModules/inspection/components/textBlock/TextBlocksTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function TextBlocksOverviewPage(props: { searchParams: SearchParams; diff --git a/employee-portal/src/app/(businessModules)/measles-protection/appointment-block-groups/new/page.tsx b/employee-portal/src/app/(businessModules)/measles-protection/appointment-block-groups/new/page.tsx index 89197bcd8210e38bee14ef05523791069340a8d3..599048097773e63a77f9d036749d79b9bbfe72e1 100644 --- a/employee-portal/src/app/(businessModules)/measles-protection/appointment-block-groups/new/page.tsx +++ b/employee-portal/src/app/(businessModules)/measles-protection/appointment-block-groups/new/page.tsx @@ -5,12 +5,13 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { useGetAppointmentDurations } from "@/lib/businessModules/measlesProtection/api/queries/appointmentTypeApi"; import { CreateAppointmentBlockGroupForm } from "@/lib/businessModules/measlesProtection/components/appointmentBlocks/CreateAppointmentBlockGroupForm"; import { routes } from "@/lib/businessModules/measlesProtection/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"; export default function NewAppointmentBlockGroupsPage() { const { data: appointmentDurationsMeasles } = useGetAppointmentDurations(); diff --git a/employee-portal/src/app/(businessModules)/measles-protection/appointment-block-groups/page.tsx b/employee-portal/src/app/(businessModules)/measles-protection/appointment-block-groups/page.tsx index f163cd98e1f8ccf75ddf2fbccd9eddef57ee19a5..f23e341e0eb179217a414d99be3c4d9e28970dd7 100644 --- a/employee-portal/src/app/(businessModules)/measles-protection/appointment-block-groups/page.tsx +++ b/employee-portal/src/app/(businessModules)/measles-protection/appointment-block-groups/page.tsx @@ -3,6 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { InternalLinkButton } from "@eshg/lib-portal/components/navigation/InternalLinkButton"; import { Schedule } from "@mui/icons-material"; @@ -10,9 +13,6 @@ import { AppointmentBlockGroupsTable } from "@/lib/businessModules/measlesProtec import { routes } from "@/lib/businessModules/measlesProtection/shared/routes"; import { ButtonBar } from "@/lib/shared/components/buttons/ButtonBar"; import { FilterButton } from "@/lib/shared/components/buttons/FilterButton"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function AppointmentBlockGroupsOverviewPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/medical-registry/procedures/[id]/layout.tsx b/employee-portal/src/app/(businessModules)/medical-registry/procedures/[id]/layout.tsx index 8553f7ed8b88d7678b8a832fe20664e21ff4b2cd..057c4d08c33fff9a317bb9702c7d5c95549165b0 100644 --- a/employee-portal/src/app/(businessModules)/medical-registry/procedures/[id]/layout.tsx +++ b/employee-portal/src/app/(businessModules)/medical-registry/procedures/[id]/layout.tsx @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; import { PropsWithChildren } from "react"; import { MedicalRegistryProcedurePageParams } from "@/app/(businessModules)/medical-registry/procedures/[id]/page"; import { MedicalRegistryTabNavigationToolbar } from "@/lib/businessModules/medicalRegistry/components/procedures/MedicalRegistryTabNavigationToolbar"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; export default function MedicalRegistryProcedureLayout({ params, diff --git a/employee-portal/src/app/(businessModules)/medical-registry/procedures/[id]/template.tsx b/employee-portal/src/app/(businessModules)/medical-registry/procedures/[id]/template.tsx index 865c3156d9377dd628eb02daee4bf9b3d71b94b5..e8763c5c0521b7fbfe451419c05e70a277c4e737 100644 --- a/employee-portal/src/app/(businessModules)/medical-registry/procedures/[id]/template.tsx +++ b/employee-portal/src/app/(businessModules)/medical-registry/procedures/[id]/template.tsx @@ -3,11 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { QueryBoundary } from "@eshg/lib-portal/components/boundaries/QueryBoundary"; import { RequiresChildren } from "@eshg/lib-portal/types/react"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; - export default function ProcedureTemplate(props: Readonly<RequiresChildren>) { return ( <QueryBoundary> diff --git a/employee-portal/src/app/(businessModules)/medical-registry/procedures/create/page.tsx b/employee-portal/src/app/(businessModules)/medical-registry/procedures/create/page.tsx index fa57436b2e850c3e9cfcb33023a8468753ea1fe9..c753811ef8bfe0cadc82eda8675397ec36029e9d 100644 --- a/employee-portal/src/app/(businessModules)/medical-registry/procedures/create/page.tsx +++ b/employee-portal/src/app/(businessModules)/medical-registry/procedures/create/page.tsx @@ -6,15 +6,15 @@ "use client"; import { ApiUserRole } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { useState } from "react"; import { MedicalRegistryCreateProcedureForm } from "@/lib/businessModules/medicalRegistry/components/procedures/create/MedicalRegistryCreateProcedureForm"; import { MedicalRegistryCreateProcedureSuccessPage } from "@/lib/businessModules/medicalRegistry/components/procedures/create/MedicalRegistryCreateProcedureSuccessPage"; import { routes } from "@/lib/businessModules/medicalRegistry/shared/routes"; import { RestrictedPage } from "@/lib/shared/components/RestrictedPage"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function MedicalRegistryCreateProcedure() { const [showSuccessPage, setShowSuccessPage] = useState(false); diff --git a/employee-portal/src/app/(businessModules)/medical-registry/procedures/page.tsx b/employee-portal/src/app/(businessModules)/medical-registry/procedures/page.tsx index 27d68fa85e80fa265c9607e77c37676cd62f2def..b2a3a7ff7021ae02113f9aed47c1088c03728411 100644 --- a/employee-portal/src/app/(businessModules)/medical-registry/procedures/page.tsx +++ b/employee-portal/src/app/(businessModules)/medical-registry/procedures/page.tsx @@ -5,10 +5,11 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { MedicalRegistryProceduresTable } from "@/lib/businessModules/medicalRegistry/components/procedures/proceduresTable/MedicalRegistryProceduresTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function MedicalRegistryProceduresPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/official-medical-service/appointment-block-groups/new/page.tsx b/employee-portal/src/app/(businessModules)/official-medical-service/appointment-block-groups/new/page.tsx index a0c3bae6b81e03e0f8aa2c3dee35134c69a3bfc4..c69299e82c0efa0e51d5eea3f5736589e43d629f 100644 --- a/employee-portal/src/app/(businessModules)/official-medical-service/appointment-block-groups/new/page.tsx +++ b/employee-portal/src/app/(businessModules)/official-medical-service/appointment-block-groups/new/page.tsx @@ -3,11 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { CreateAppointmentBlockGroupForm } from "@/lib/businessModules/officialMedicalService/components/appointmentBlocks/appointmentBlocksGroupForm/CreateAppointmentBlockGroupForm"; import { routes } from "@/lib/businessModules/officialMedicalService/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"; export default function NewAppointmentBlockPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/official-medical-service/appointment-block-groups/page.tsx b/employee-portal/src/app/(businessModules)/official-medical-service/appointment-block-groups/page.tsx index 3fa10e3c2f058bc2144af95161c82eca926bf6ca..c15a89d94823fdb3f2ef0c1acf28a6d194613f48 100644 --- a/employee-portal/src/app/(businessModules)/official-medical-service/appointment-block-groups/page.tsx +++ b/employee-portal/src/app/(businessModules)/official-medical-service/appointment-block-groups/page.tsx @@ -3,15 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { InternalLinkButton } from "@eshg/lib-portal/components/navigation/InternalLinkButton"; import { Schedule } from "@mui/icons-material"; import { AppointmentBlockGroupsTable } from "@/lib/businessModules/officialMedicalService/components/appointmentBlocks/appointmentBlocksTable/AppointmentBlockGroupTable"; import { routes } from "@/lib/businessModules/officialMedicalService/shared/routes"; import { ButtonBar } from "@/lib/shared/components/buttons/ButtonBar"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function AppointmentBlockGroupsPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/official-medical-service/procedures/[id]/layout.tsx b/employee-portal/src/app/(businessModules)/official-medical-service/procedures/[id]/layout.tsx index 2811303635126812bb4dc60db0bee7ef7a03ff93..4dbf80ccec0ba1a43d8c4ee993173f1ff249ed51 100644 --- a/employee-portal/src/app/(businessModules)/official-medical-service/procedures/[id]/layout.tsx +++ b/employee-portal/src/app/(businessModules)/official-medical-service/procedures/[id]/layout.tsx @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; import { PropsWithChildren } from "react"; import { ProcedureDetailsToolbar } from "@/lib/businessModules/officialMedicalService/components/procedures/details/ProceduresDetailsToolbar"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; export type OfficialMedicalServiceDetailsPageProps = Readonly<{ params: OfficialMedicalServiceDetailsPageParams; diff --git a/employee-portal/src/app/(businessModules)/official-medical-service/procedures/page.tsx b/employee-portal/src/app/(businessModules)/official-medical-service/procedures/page.tsx index 20044971d57c9eaf04b8a0b6c6a4562fa65fe65e..172b3449f93feb8a1ecdfe66d7c00f8fe9f1f7da 100644 --- a/employee-portal/src/app/(businessModules)/official-medical-service/procedures/page.tsx +++ b/employee-portal/src/app/(businessModules)/official-medical-service/procedures/page.tsx @@ -3,22 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { SearchParams } from "@eshg/lib-portal/helpers/searchParams"; import { CreateProcedure } from "@/lib/businessModules/officialMedicalService/components/procedures/overview/CreateProcedure"; import { ProceduresOverviewTable } from "@/lib/businessModules/officialMedicalService/components/procedures/overview/ProceduresOverviewTable"; -import { OverlayBoundary } from "@/lib/shared/components/boundaries/OverlayBoundary"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; - -function CreateProcedureButton() { - return ( - <OverlayBoundary> - <CreateProcedure /> - </OverlayBoundary> - ); -} export default function OfficialMedicalServiceProceduresPage( props: Readonly<{ @@ -29,7 +20,7 @@ export default function OfficialMedicalServiceProceduresPage( <StickyToolbarLayout toolbar={<Toolbar title="Amtsärztlicher Dienst" />}> <MainContentLayout fullViewportHeight> <ProceduresOverviewTable - buttons={[<CreateProcedureButton key="createProcedure" />]} + buttons={[<CreateProcedure key="createProcedure" />]} filter={props.searchParams} /> </MainContentLayout> diff --git a/employee-portal/src/app/(businessModules)/official-medical-service/waiting-room/page.tsx b/employee-portal/src/app/(businessModules)/official-medical-service/waiting-room/page.tsx index 42113fd3a7f433e1f7968cc58af2e1447c69b552..f57819a1027a02bd163e3b5c9fe517f1e0579e45 100644 --- a/employee-portal/src/app/(businessModules)/official-medical-service/waiting-room/page.tsx +++ b/employee-portal/src/app/(businessModules)/official-medical-service/waiting-room/page.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { WaitingRoomTable } from "@/lib/businessModules/officialMedicalService/components/waitingRoom/WaitingRoomTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function OmsWaitingRoomPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/school-entry/appointment-block-groups/new/page.tsx b/employee-portal/src/app/(businessModules)/school-entry/appointment-block-groups/new/page.tsx index 7778565e1b319a2ee57830fea36a0150e261e44f..c675dc21a68aa3ff710b2e5c03bc227af4e28751 100644 --- a/employee-portal/src/app/(businessModules)/school-entry/appointment-block-groups/new/page.tsx +++ b/employee-portal/src/app/(businessModules)/school-entry/appointment-block-groups/new/page.tsx @@ -3,11 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { CreateAppointmentBlockGroupForm } from "@/lib/businessModules/schoolEntry/features/appointmentBlocks/appointmentBlocksGroupForm/CreateAppointmentBlockGroupForm"; import { routes } from "@/lib/businessModules/schoolEntry/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"; export default function NewAppointmentBlockPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/school-entry/appointment-block-groups/page.tsx b/employee-portal/src/app/(businessModules)/school-entry/appointment-block-groups/page.tsx index 895b4edaf0771b74afa347c975fced1494fc1cea..acdbf48bf7aae1f042eb9d32c48274471948fbf2 100644 --- a/employee-portal/src/app/(businessModules)/school-entry/appointment-block-groups/page.tsx +++ b/employee-portal/src/app/(businessModules)/school-entry/appointment-block-groups/page.tsx @@ -3,6 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { InternalLinkButton } from "@eshg/lib-portal/components/navigation/InternalLinkButton"; import { Schedule } from "@mui/icons-material"; @@ -10,9 +13,6 @@ import { AppointmentBlockGroupsTable } from "@/lib/businessModules/schoolEntry/f import { routes } from "@/lib/businessModules/schoolEntry/shared/routes"; import { ButtonBar } from "@/lib/shared/components/buttons/ButtonBar"; import { FilterButton } from "@/lib/shared/components/buttons/FilterButton"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function AppointmentBlockGroupsOverviewPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/school-entry/labels/page.tsx b/employee-portal/src/app/(businessModules)/school-entry/labels/page.tsx index 75879d2f21a7cbe0424e73214489fd0049e75e3b..c83e6d04bc17a5f10d44955c283d0ef821bca393 100644 --- a/employee-portal/src/app/(businessModules)/school-entry/labels/page.tsx +++ b/employee-portal/src/app/(businessModules)/school-entry/labels/page.tsx @@ -5,11 +5,12 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { useGetLabels } from "@/lib/businessModules/schoolEntry/api/queries/labelApi"; import { LabelsTable } from "@/lib/businessModules/schoolEntry/features/labels/LabelsTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function LabelsOverviewPage() { const getLabels = useGetLabels(); diff --git a/employee-portal/src/app/(businessModules)/school-entry/procedures/[procedureId]/layout.tsx b/employee-portal/src/app/(businessModules)/school-entry/procedures/[procedureId]/layout.tsx index 47a7d75d5a6b75a69fc0df5a253c39e6842275e7..ecc321c0b5e0c3480784fbe321c2ec3c4c39bb33 100644 --- a/employee-portal/src/app/(businessModules)/school-entry/procedures/[procedureId]/layout.tsx +++ b/employee-portal/src/app/(businessModules)/school-entry/procedures/[procedureId]/layout.tsx @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; import { PropsWithChildren } from "react"; import { ProcedureToolbar } from "@/lib/businessModules/schoolEntry/features/procedures/ProcedureToolbar"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; export type SchoolEntryProcedurePageProps = Readonly<{ params: SchoolEntryProcedurePageParams; diff --git a/employee-portal/src/app/(businessModules)/school-entry/procedures/page.tsx b/employee-portal/src/app/(businessModules)/school-entry/procedures/page.tsx index c5b4c6b1c8851c74c852284841bba1cfc1fb691d..a71410ed3efb356512a528ea5d8ecf48733fb433 100644 --- a/employee-portal/src/app/(businessModules)/school-entry/procedures/page.tsx +++ b/employee-portal/src/app/(businessModules)/school-entry/procedures/page.tsx @@ -5,6 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { Cached } from "@mui/icons-material"; import { Button } from "@mui/joy"; @@ -12,10 +15,6 @@ import { useImportDataSidebar } from "@/lib/businessModules/schoolEntry/features import { CreateProcedureSidebar } from "@/lib/businessModules/schoolEntry/features/procedures/new/CreateProcedureSidebar"; import { BUTTON_SIZE } from "@/lib/businessModules/schoolEntry/features/procedures/new/constants"; import { ProceduresTable } from "@/lib/businessModules/schoolEntry/features/procedures/proceduresTable/ProceduresTable"; -import { OverlayBoundary } from "@/lib/shared/components/boundaries/OverlayBoundary"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; function ImportDataButton() { const importDataSidebar = useImportDataSidebar(); @@ -32,14 +31,6 @@ function ImportDataButton() { ); } -function CreateProcedureButton() { - return ( - <OverlayBoundary> - <CreateProcedureSidebar /> - </OverlayBoundary> - ); -} - export default function SchoolEntryProceduresPage() { return ( <StickyToolbarLayout toolbar={<Toolbar title="Einschulungsuntersuchung" />}> @@ -47,7 +38,7 @@ export default function SchoolEntryProceduresPage() { <ProceduresTable buttons={[ <ImportDataButton key="importData" />, - <CreateProcedureButton key="createProcedure" />, + <CreateProcedureSidebar key="createProcedure" />, ]} /> </MainContentLayout> diff --git a/employee-portal/src/app/(businessModules)/school-entry/waiting-room/page.tsx b/employee-portal/src/app/(businessModules)/school-entry/waiting-room/page.tsx index 15bb4eae04f3b208c476384913b3290f8d9897a4..af90010b0e12e0d4b38db1fda09798dac9df123c 100644 --- a/employee-portal/src/app/(businessModules)/school-entry/waiting-room/page.tsx +++ b/employee-portal/src/app/(businessModules)/school-entry/waiting-room/page.tsx @@ -5,10 +5,11 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { WaitingRoomTable } from "@/lib/businessModules/schoolEntry/features/waitingRoom/WaitingRoomTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function SchoolEntryWaitingRoomPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/statistics/evaluations/[id]/data-quality/page.tsx b/employee-portal/src/app/(businessModules)/statistics/evaluations/[id]/data-quality/page.tsx index 286d6e0d01643a2e965d1316a31fb2b64ed2e9be..40c3c90960aaa5efeb7209d4ea975647c871eb82 100644 --- a/employee-portal/src/app/(businessModules)/statistics/evaluations/[id]/data-quality/page.tsx +++ b/employee-portal/src/app/(businessModules)/statistics/evaluations/[id]/data-quality/page.tsx @@ -5,10 +5,11 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; + import { useGetCompletenessInformation } from "@/lib/businessModules/statistics/api/queries/useGetCompletenessInformation"; import { EvaluationDetailsLayout } from "@/lib/businessModules/statistics/components/evaluations/details/EvaluationDetailsLayout"; import { EvaluationDataQuality } from "@/lib/businessModules/statistics/components/evaluations/details/dataQuality/EvaluationDataQuality"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; export default function EvaluationDetailsDataQualityPage( props: Readonly<{ params: { id: string } }>, diff --git a/employee-portal/src/app/(businessModules)/statistics/evaluations/[id]/page.tsx b/employee-portal/src/app/(businessModules)/statistics/evaluations/[id]/page.tsx index 7ade91515d4d719c04ccaa800e4466ddcd8dd4d8..bd4cdaac677c4b9aa303cafd354afaecd8f18dd4 100644 --- a/employee-portal/src/app/(businessModules)/statistics/evaluations/[id]/page.tsx +++ b/employee-portal/src/app/(businessModules)/statistics/evaluations/[id]/page.tsx @@ -5,10 +5,11 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; + import { useGetEvaluationDetailsPage } from "@/lib/businessModules/statistics/api/queries/useGetEvaluationDetailsPage"; import { EvaluationDetails } from "@/lib/businessModules/statistics/components/evaluations/details/EvaluationDetails"; import { EvaluationDetailsLayout } from "@/lib/businessModules/statistics/components/evaluations/details/EvaluationDetailsLayout"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; export default function EvaluationDetailsPage( props: Readonly<{ diff --git a/employee-portal/src/app/(businessModules)/statistics/evaluations/[id]/reports/page.tsx b/employee-portal/src/app/(businessModules)/statistics/evaluations/[id]/reports/page.tsx index 8816566603fcd4bbd91f19d6551fe58d1f2ca5c1..807b39fa7220c22adc49e0edf12808177b4e236b 100644 --- a/employee-portal/src/app/(businessModules)/statistics/evaluations/[id]/reports/page.tsx +++ b/employee-portal/src/app/(businessModules)/statistics/evaluations/[id]/reports/page.tsx @@ -5,10 +5,11 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; + import { useGetEvaluationReports } from "@/lib/businessModules/statistics/api/queries/useGetEvaluationReports"; import { EvaluationDetailsLayout } from "@/lib/businessModules/statistics/components/evaluations/details/EvaluationDetailsLayout"; import { EvaluationReports } from "@/lib/businessModules/statistics/components/evaluations/details/reports/EvaluationReports"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; export default function EvaluationDetailsReportsPage( props: Readonly<{ params: { id: string } }>, diff --git a/employee-portal/src/app/(businessModules)/statistics/evaluations/[id]/table/page.tsx b/employee-portal/src/app/(businessModules)/statistics/evaluations/[id]/table/page.tsx index 387f460aea7a38fd526dec5b934ba7ba754da934..3029fe99e76a684e7237e720f4a73d8d4a45722a 100644 --- a/employee-portal/src/app/(businessModules)/statistics/evaluations/[id]/table/page.tsx +++ b/employee-portal/src/app/(businessModules)/statistics/evaluations/[id]/table/page.tsx @@ -5,6 +5,7 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { ApiSortDirection } from "@eshg/statistics-api"; import { startTransition, useEffect, useState } from "react"; import { isDefined } from "remeda"; @@ -23,7 +24,6 @@ import { EvaluationDetailsTable, EvaluationDetailsTableProps, } from "@/lib/businessModules/statistics/components/evaluations/details/table/EvaluationDetailsTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; import { usePagination } from "@/lib/shared/hooks/table/usePagination"; import { useTableSorting } from "@/lib/shared/hooks/table/useTableSorting"; diff --git a/employee-portal/src/app/(businessModules)/statistics/evaluations/page.tsx b/employee-portal/src/app/(businessModules)/statistics/evaluations/page.tsx index 9a608f4dbbe7c6cdec3f8fe335fb5ea819295403..076eee544c34281a9dc2375208d4fb8e85107bcb 100644 --- a/employee-portal/src/app/(businessModules)/statistics/evaluations/page.tsx +++ b/employee-portal/src/app/(businessModules)/statistics/evaluations/page.tsx @@ -5,10 +5,11 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { EvaluationsOverview } from "@/lib/businessModules/statistics/components/evaluations/EvaluationsOverview"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function EvaluationsOverviewPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/statistics/evaluations/templates/layout.tsx b/employee-portal/src/app/(businessModules)/statistics/evaluations/templates/layout.tsx index f4e8744c9800b3645be7946fbdf3e2eeae297718..f657dd0d7499796089ba2dc0b39cb42a21f55b74 100644 --- a/employee-portal/src/app/(businessModules)/statistics/evaluations/templates/layout.tsx +++ b/employee-portal/src/app/(businessModules)/statistics/evaluations/templates/layout.tsx @@ -6,6 +6,8 @@ "use client"; import { ApiUserRole } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; import { hasAnyUserRoles } from "@eshg/lib-employee-portal/helpers/accessControl"; import { RequiresChildren } from "@eshg/lib-portal/types/react"; import { @@ -14,8 +16,6 @@ import { } from "@mui/icons-material"; import { routes } from "@/lib/businessModules/statistics/shared/routes"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; import { TabNavigationItem } from "@/lib/shared/components/tabNavigation/types"; import { TabNavigationHeader, diff --git a/employee-portal/src/app/(businessModules)/statistics/geo-shapes/page.tsx b/employee-portal/src/app/(businessModules)/statistics/geo-shapes/page.tsx index dbc260a3878c29fcf1be7549beacf97773ceb45d..82ce5158343f14952489b01d2898704c60304abd 100644 --- a/employee-portal/src/app/(businessModules)/statistics/geo-shapes/page.tsx +++ b/employee-portal/src/app/(businessModules)/statistics/geo-shapes/page.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { GeoShapesOverview } from "@/lib/businessModules/statistics/components/geoshapes/GeoShapesOverview"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function GeoShapesOverviewPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/statistics/reports/[id]/page.tsx b/employee-portal/src/app/(businessModules)/statistics/reports/[id]/page.tsx index 404ef6ff2cc852fe39c0027b2f7c4e171338d56c..c385df14ab1a4c1968d685d92372df5386ba6c4d 100644 --- a/employee-portal/src/app/(businessModules)/statistics/reports/[id]/page.tsx +++ b/employee-portal/src/app/(businessModules)/statistics/reports/[id]/page.tsx @@ -5,12 +5,13 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { useGetReportDetails } from "@/lib/businessModules/statistics/api/queries/useGetReportDetails"; import { ReportDetails } from "@/lib/businessModules/statistics/components/reports/ReportDetails"; import { routes } from "@/lib/businessModules/statistics/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"; export default function ReportDetailsPage( props: Readonly<{ params: { id: string } }>, diff --git a/employee-portal/src/app/(businessModules)/statistics/reports/page.tsx b/employee-portal/src/app/(businessModules)/statistics/reports/page.tsx index 4b506842b16283b4914056ee07cb6f2ddddeb2aa..fd3af255f5114331812078a3a8f496119bb4dc05 100644 --- a/employee-portal/src/app/(businessModules)/statistics/reports/page.tsx +++ b/employee-portal/src/app/(businessModules)/statistics/reports/page.tsx @@ -5,10 +5,11 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { ReportsOverview } from "@/lib/businessModules/statistics/components/reports/ReportsOverview"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function ReportsOverviewPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/sti-protection/appointment-block-groups/new/page.tsx b/employee-portal/src/app/(businessModules)/sti-protection/appointment-block-groups/new/page.tsx index 7a01cbb776b93ce71ac4452ae867f47dde0933ad..f7188d80c0a496eb7d1aa19d8701233345535304 100644 --- a/employee-portal/src/app/(businessModules)/sti-protection/appointment-block-groups/new/page.tsx +++ b/employee-portal/src/app/(businessModules)/sti-protection/appointment-block-groups/new/page.tsx @@ -5,11 +5,12 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { CreateAppointmentBlockGroupForm } from "@/lib/businessModules/stiProtection/components/appointmentBlocks/CreateAppointmentBlockGroupForm"; import { routes } from "@/lib/businessModules/stiProtection/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"; export default function NewAppointmentBlockGroupsPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/sti-protection/appointment-block-groups/page.tsx b/employee-portal/src/app/(businessModules)/sti-protection/appointment-block-groups/page.tsx index bc48a1f4068ce8b4a37a1da181434d32927c2727..ecaf3959eab1ab60fbd52e4a37799a916c40acdc 100644 --- a/employee-portal/src/app/(businessModules)/sti-protection/appointment-block-groups/page.tsx +++ b/employee-portal/src/app/(businessModules)/sti-protection/appointment-block-groups/page.tsx @@ -3,15 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { InternalLinkButton } from "@eshg/lib-portal/components/navigation/InternalLinkButton"; import { Schedule } from "@mui/icons-material"; import { AppointmentBlockGroupsTable } from "@/lib/businessModules/stiProtection/components/appointmentBlocks/AppointmentBlockGroupsTable"; import { routes } from "@/lib/businessModules/stiProtection/shared/routes"; import { ButtonBar } from "@/lib/shared/components/buttons/ButtonBar"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function AppointmentBlockGroupsOverviewPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/sti-protection/appointment-definition/page.tsx b/employee-portal/src/app/(businessModules)/sti-protection/appointment-definition/page.tsx index cd7033cae164878ee8e135881fe9ec42c95bd0dc..b4cb7f9ebaf8ca0047cdfdf83cd99e6ca22a16eb 100644 --- a/employee-portal/src/app/(businessModules)/sti-protection/appointment-definition/page.tsx +++ b/employee-portal/src/app/(businessModules)/sti-protection/appointment-definition/page.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { AppointmentTypeOverviewTable } from "@/lib/businessModules/stiProtection/components/appointmentTypes/AppointmentTypeOverviewTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function AppointmentTypeOverviewPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(framedPageLayout)/layout.tsx b/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(framedPageLayout)/layout.tsx index c578eb66ee198e956babe76bc3fb9fb443e2d369..8cd2c3244b57394abbccef8585f93bb029b57324 100644 --- a/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(framedPageLayout)/layout.tsx +++ b/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(framedPageLayout)/layout.tsx @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; import { ReactNode } from "react"; import { ProcedureToolbar } from "@/lib/businessModules/stiProtection/features/procedures/ProcedureToolbar"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; export interface StiProtectionProcedurePageParams { id: string; diff --git a/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/consultation/page.tsx b/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/consultation/page.tsx index 7c1b4816683f12153a74312225896cbea06334c0..ada4ae11c8c19f474339ca86db897bf6de39ef71 100644 --- a/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/consultation/page.tsx +++ b/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/consultation/page.tsx @@ -5,6 +5,7 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { DisabledFormProvider } from "@eshg/lib-portal/components/form/DisabledFormContext"; import { useSuspenseQueries } from "@tanstack/react-query"; @@ -13,7 +14,6 @@ import { useConsultationQueryOptions } from "@/lib/businessModules/stiProtection import { useStiProcedureQueryOptions } from "@/lib/businessModules/stiProtection/api/queries/procedures"; import { ConsultationForm } from "@/lib/businessModules/stiProtection/features/procedures/consultation/ConsultationForm"; import { isProcedureOpen } from "@/lib/businessModules/stiProtection/shared/helpers"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; export default function ConsultationPage({ params: { id: procedureId }, diff --git a/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/diagnosis/page.tsx b/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/diagnosis/page.tsx index bcaddc6b120597e607cbd9e47bc7c9c5ad2cf206..0e0abdbc9b3395852969f86e8723165ffc349af4 100644 --- a/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/diagnosis/page.tsx +++ b/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/diagnosis/page.tsx @@ -5,6 +5,7 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { DisabledFormProvider } from "@eshg/lib-portal/components/form/DisabledFormContext"; import { useSuspenseQueries } from "@tanstack/react-query"; @@ -13,7 +14,6 @@ import { useDiagnosisQueryOptions } from "@/lib/businessModules/stiProtection/ap import { useStiProcedureQueryOptions } from "@/lib/businessModules/stiProtection/api/queries/procedures"; import { DiagnosisForm } from "@/lib/businessModules/stiProtection/features/procedures/diagnosis/DiagnosisForm"; import { isProcedureOpen } from "@/lib/businessModules/stiProtection/shared/helpers"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; export default function StiProtectionProcedureDiagnosisPage({ params: { id: procedureId }, diff --git a/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/examination/laboratory-test/page.tsx b/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/examination/laboratory-test/page.tsx index 740e4370992cdd0e53897419c7a2f04b59ac2cc6..c10f4c0e47fc2376ce8b05a4de6ff470573f5909 100644 --- a/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/examination/laboratory-test/page.tsx +++ b/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/examination/laboratory-test/page.tsx @@ -5,6 +5,7 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { DisabledFormProvider } from "@eshg/lib-portal/components/form/DisabledFormContext"; import { StiProtectionProcedurePageParams } from "@/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/layout"; @@ -12,7 +13,6 @@ import { useGetLaboratoryTestExaminationQuery } from "@/lib/businessModules/stiP import { useStiProcedureQuery } from "@/lib/businessModules/stiProtection/api/queries/procedures"; import { LaboratoryTestExamination } from "@/lib/businessModules/stiProtection/features/procedures/examination/laboratoryTest/LaboratoryTestExamination"; import { isProcedureOpen } from "@/lib/businessModules/stiProtection/shared/helpers"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; export default function StiProtectionProcedureLaboratoryTestPage({ params: { id: procedureId }, diff --git a/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/examination/page.tsx b/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/examination/page.tsx index cba88fdf90f6edc8d7009c43d9d20efe6d81b710..e3d686bbd636fa6c513bf29f5cb5826dff350489 100644 --- a/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/examination/page.tsx +++ b/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/examination/page.tsx @@ -15,5 +15,5 @@ export default function StiProtectionProcedureExaminationPage({ }: Readonly<{ params: StiProtectionProcedurePageParams; }>) { - redirect(routes.procedures.byId(procedureId).rapidTest); + redirect(routes.procedures.byId(procedureId).examination.rapidTest); } diff --git a/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/examination/rapid-test/page.tsx b/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/examination/rapid-test/page.tsx index bd51610acb42fc4908c9fa2f60c6351936d765cd..2052f6e4ba269cb1e4a630d297eae362129d5faf 100644 --- a/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/examination/rapid-test/page.tsx +++ b/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/examination/rapid-test/page.tsx @@ -5,24 +5,29 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { DisabledFormProvider } from "@eshg/lib-portal/components/form/DisabledFormContext"; +import { useSuspenseQueries } from "@tanstack/react-query"; import { StiProtectionProcedurePageParams } from "@/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/layout"; -import { useGetRapidTestExaminationQuery } from "@/lib/businessModules/stiProtection/api/queries/examination"; -import { useStiProcedureQuery } from "@/lib/businessModules/stiProtection/api/queries/procedures"; +import { useGetRapidTestExaminationQueryOptions } from "@/lib/businessModules/stiProtection/api/queries/examination"; +import { useStiProcedureQueryOptions } from "@/lib/businessModules/stiProtection/api/queries/procedures"; import { RapidTestExamination } from "@/lib/businessModules/stiProtection/features/procedures/examination/rapidTest/RapidTestExamination"; import { isProcedureOpen } from "@/lib/businessModules/stiProtection/shared/helpers"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; export default function StiProtectionProcedureRapidTestPage({ params: { id: procedureId }, }: Readonly<{ params: StiProtectionProcedurePageParams; }>) { - const { data: procedure } = useStiProcedureQuery(procedureId); + const [{ data: procedure }, { data: rapidTestExamination }] = + useSuspenseQueries({ + queries: [ + useStiProcedureQueryOptions(procedureId), + useGetRapidTestExaminationQueryOptions(procedureId), + ], + }); const isOpen = isProcedureOpen(procedure); - const { data: rapidTestExamination } = - useGetRapidTestExaminationQuery(procedureId); return ( <DisabledFormProvider disabled={!isOpen}> diff --git a/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/layout.tsx b/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/layout.tsx index ac66ec525f4f593ff398fb729466335c2cb5aa52..a789f0b19a056d7287c22ae8d93a0469d44ad430 100644 --- a/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/layout.tsx +++ b/employee-portal/src/app/(businessModules)/sti-protection/procedures/[id]/(fullPageLayout)/layout.tsx @@ -3,10 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; import { ReactNode } from "react"; import { ProcedureToolbar } from "@/lib/businessModules/stiProtection/features/procedures/ProcedureToolbar"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; export interface StiProtectionProcedurePageParams { id: string; diff --git a/employee-portal/src/app/(businessModules)/sti-protection/procedures/page.tsx b/employee-portal/src/app/(businessModules)/sti-protection/procedures/page.tsx index 623ad9f076b6cb3fd540dd4ab711c25d6af2c048..6f8e9cc9be30744cc01c0389ade36aefad2d8130 100644 --- a/employee-portal/src/app/(businessModules)/sti-protection/procedures/page.tsx +++ b/employee-portal/src/app/(businessModules)/sti-protection/procedures/page.tsx @@ -5,14 +5,14 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { Stack } from "@mui/joy"; import { StiProtectionProceduresSearchBar } from "@/lib/businessModules/stiProtection/components/procedures/proceduresTable/StiProtectionProceduresSearchBar"; import { StiProtectionProceduresTable } from "@/lib/businessModules/stiProtection/components/procedures/proceduresTable/StiProtectionProceduresTable"; import { AddNewProcedureSidebar } from "@/lib/businessModules/stiProtection/features/procedures/addNewProcedure/AddNewProcedureSidebar"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function STIProtectionProceduresPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/sti-protection/text-templates/page.tsx b/employee-portal/src/app/(businessModules)/sti-protection/text-templates/page.tsx index 0e48f3d2a7e3edec9ba939e5f9bcba51fb8c6136..65f9a2e757c8d6d336d35487d16bf025b6eb27f3 100644 --- a/employee-portal/src/app/(businessModules)/sti-protection/text-templates/page.tsx +++ b/employee-portal/src/app/(businessModules)/sti-protection/text-templates/page.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { TextTemplatesOverviewTable } from "@/lib/businessModules/stiProtection/components/textTemplates/TextTemplatesOverviewTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function TextTemplatesOverviewPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/sti-protection/waiting-room/page.tsx b/employee-portal/src/app/(businessModules)/sti-protection/waiting-room/page.tsx index 2432e8b6c018d4abaedf8f5925ccb20b9aebad05..4b483d10fcfae2187977189ac45fb6034c3b6a9f 100644 --- a/employee-portal/src/app/(businessModules)/sti-protection/waiting-room/page.tsx +++ b/employee-portal/src/app/(businessModules)/sti-protection/waiting-room/page.tsx @@ -5,10 +5,11 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { WaitingRoomTable } from "@/lib/businessModules/stiProtection/features/waitingRoom/WaitingRoomTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function STIProtectionWaitingRoomPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/travel-medicine/appointment-block-groups/new/page.tsx b/employee-portal/src/app/(businessModules)/travel-medicine/appointment-block-groups/new/page.tsx index 5b8f8871f9e85a295256dfe25619b4081bbc33ad..99cf7a6b1456c616ccd1b4b57c73eb5153aa82de 100644 --- a/employee-portal/src/app/(businessModules)/travel-medicine/appointment-block-groups/new/page.tsx +++ b/employee-portal/src/app/(businessModules)/travel-medicine/appointment-block-groups/new/page.tsx @@ -3,11 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { CreateAppointmentBlockGroupForm } from "@/lib/businessModules/travelMedicine/components/appointmentBlocks/appointmentBlocksGroupForm/CreateAppointmentBlockGroupForm"; import { routes } from "@/lib/businessModules/travelMedicine/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"; export default function NewAppointmentBlockGroupPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/travel-medicine/appointment-block-groups/page.tsx b/employee-portal/src/app/(businessModules)/travel-medicine/appointment-block-groups/page.tsx index 6be5b96419a7d766877fa427d15fffe69b24f906..125a633d2982a6c611b233778eac25386480b578 100644 --- a/employee-portal/src/app/(businessModules)/travel-medicine/appointment-block-groups/page.tsx +++ b/employee-portal/src/app/(businessModules)/travel-medicine/appointment-block-groups/page.tsx @@ -3,15 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { InternalLinkButton } from "@eshg/lib-portal/components/navigation/InternalLinkButton"; import { Schedule } from "@mui/icons-material"; import { AppointmentBlockGroupsTable } from "@/lib/businessModules/travelMedicine/components/appointmentBlocks/appointmentBlocksTable/AppointmentBlockGroupsTable"; import { routes } from "@/lib/businessModules/travelMedicine/shared/routes"; import { ButtonBar } from "@/lib/shared/components/buttons/ButtonBar"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function AppointmentBlockGroupsPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/travel-medicine/appointment-definition/page.tsx b/employee-portal/src/app/(businessModules)/travel-medicine/appointment-definition/page.tsx index 312d151cb72e7bbb54547fb364c4b35b0b68af64..baa6c192fe15f4258d2421468cc7b9319ebc65a9 100644 --- a/employee-portal/src/app/(businessModules)/travel-medicine/appointment-definition/page.tsx +++ b/employee-portal/src/app/(businessModules)/travel-medicine/appointment-definition/page.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { AppointmentTypesTable } from "@/lib/businessModules/travelMedicine/components/appointmentTypes/AppointmentTypesTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function AppointmentTypeOverviewPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/travel-medicine/diseases/page.tsx b/employee-portal/src/app/(businessModules)/travel-medicine/diseases/page.tsx index 73dd23aaefd0278b2522556f25f9a68e091ed49a..a3a16775d505dc9160d5bf3ca90e01c1a415ba72 100644 --- a/employee-portal/src/app/(businessModules)/travel-medicine/diseases/page.tsx +++ b/employee-portal/src/app/(businessModules)/travel-medicine/diseases/page.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { DiseasesTable } from "@/lib/businessModules/travelMedicine/components/diseases/DiseasesTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function DiseasesOverviewPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/travel-medicine/information-statement-templates/[id]/page.tsx b/employee-portal/src/app/(businessModules)/travel-medicine/information-statement-templates/[id]/page.tsx index 0d8067d37af523fdf64818240b7617d64004e9ef..bc6b0edc668dbd458ca902b4bf893ef0d0caa6a9 100644 --- a/employee-portal/src/app/(businessModules)/travel-medicine/information-statement-templates/[id]/page.tsx +++ b/employee-portal/src/app/(businessModules)/travel-medicine/information-statement-templates/[id]/page.tsx @@ -5,11 +5,12 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { InformationStatementTemplateEditor } from "@/lib/businessModules/travelMedicine/components/templates/informationStatement/InformationStatementTemplateEditor"; import { routes } from "@/lib/businessModules/travelMedicine/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"; export default function InformationStatementDetailsPage({ params, diff --git a/employee-portal/src/app/(businessModules)/travel-medicine/information-statement-templates/new/page.tsx b/employee-portal/src/app/(businessModules)/travel-medicine/information-statement-templates/new/page.tsx index 4d4d99c1779238cc31f86eb70fbd14c5f7c44799..9c6c181251902ef1fd8bc53bdf577773a2e0bc4a 100644 --- a/employee-portal/src/app/(businessModules)/travel-medicine/information-statement-templates/new/page.tsx +++ b/employee-portal/src/app/(businessModules)/travel-medicine/information-statement-templates/new/page.tsx @@ -5,11 +5,12 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { InformationStatementTemplateEditor } from "@/lib/businessModules/travelMedicine/components/templates/informationStatement/InformationStatementTemplateEditor"; import { routes } from "@/lib/businessModules/travelMedicine/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"; export default function NewInformationStatementTemplatePage() { return ( diff --git a/employee-portal/src/app/(businessModules)/travel-medicine/information-statement-templates/page.tsx b/employee-portal/src/app/(businessModules)/travel-medicine/information-statement-templates/page.tsx index c7f48e4f13b4db2881a11a517f0dc6958226a81c..245be985dc283ac70f04f4658db66b295ccc9b05 100644 --- a/employee-portal/src/app/(businessModules)/travel-medicine/information-statement-templates/page.tsx +++ b/employee-portal/src/app/(businessModules)/travel-medicine/information-statement-templates/page.tsx @@ -5,10 +5,11 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { InformationStatementTemplateOverviewTable } from "@/lib/businessModules/travelMedicine/components/templates/informationStatement/InformationStatementTemplateOverviewTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function InformationStatementTemplateOverviewPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/travel-medicine/medical-history-templates/[id]/page.tsx b/employee-portal/src/app/(businessModules)/travel-medicine/medical-history-templates/[id]/page.tsx index e838f987c3fd4baedee0c47944ba56b282511290..88a7e310fbc5a7c6ad6765056533a4b285a32b6b 100644 --- a/employee-portal/src/app/(businessModules)/travel-medicine/medical-history-templates/[id]/page.tsx +++ b/employee-portal/src/app/(businessModules)/travel-medicine/medical-history-templates/[id]/page.tsx @@ -5,11 +5,12 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { MedicalHistoryTemplateEditor } from "@/lib/businessModules/travelMedicine/components/templates/medicalHistory/MedicalHistoryTemplateEditor"; import { routes } from "@/lib/businessModules/travelMedicine/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"; export default function MedicalHistoryDetailsPage({ params, diff --git a/employee-portal/src/app/(businessModules)/travel-medicine/medical-history-templates/new/page.tsx b/employee-portal/src/app/(businessModules)/travel-medicine/medical-history-templates/new/page.tsx index c97bd20d2787fc694bf5981514af5baa8149c0fc..5f2438c0af7580c91e458e397221cb338535c11c 100644 --- a/employee-portal/src/app/(businessModules)/travel-medicine/medical-history-templates/new/page.tsx +++ b/employee-portal/src/app/(businessModules)/travel-medicine/medical-history-templates/new/page.tsx @@ -5,11 +5,12 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { MedicalHistoryTemplateEditor } from "@/lib/businessModules/travelMedicine/components/templates/medicalHistory/MedicalHistoryTemplateEditor"; import { routes } from "@/lib/businessModules/travelMedicine/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"; export default function NewMedicalHistoryTemplatePage() { return ( diff --git a/employee-portal/src/app/(businessModules)/travel-medicine/medical-history-templates/page.tsx b/employee-portal/src/app/(businessModules)/travel-medicine/medical-history-templates/page.tsx index 0591ab24bf90cbe5130115cf0f04aa68ed65454d..ea895b879d7c4009a680f96314c51504709a4620 100644 --- a/employee-portal/src/app/(businessModules)/travel-medicine/medical-history-templates/page.tsx +++ b/employee-portal/src/app/(businessModules)/travel-medicine/medical-history-templates/page.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { MedicalHistoryTemplateOverviewTable } from "@/lib/businessModules/travelMedicine/components/templates/medicalHistory/MedicalHistoryTemplateOverviewTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function TemplateOverviewPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/travel-medicine/other-services/page.tsx b/employee-portal/src/app/(businessModules)/travel-medicine/other-services/page.tsx index 8ff3e461c5d8732ab0f31a52e8fb4611e9f5f1d9..b7c0fe2e96c83e88062cda981eba3bd9b1db7ce1 100644 --- a/employee-portal/src/app/(businessModules)/travel-medicine/other-services/page.tsx +++ b/employee-portal/src/app/(businessModules)/travel-medicine/other-services/page.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { OtherServiceTable } from "@/lib/businessModules/travelMedicine/components/otherServiceTemplates/OtherServiceTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function OtherServicesOverviewPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/travel-medicine/procedure/[id]/layout.tsx b/employee-portal/src/app/(businessModules)/travel-medicine/procedure/[id]/layout.tsx index e90d969e5fb0640c2be24903ebe00962764de0cc..229d5dfbcfcd17d64cf71061a81f82a70563dee5 100644 --- a/employee-portal/src/app/(businessModules)/travel-medicine/procedure/[id]/layout.tsx +++ b/employee-portal/src/app/(businessModules)/travel-medicine/procedure/[id]/layout.tsx @@ -3,12 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; import { PropsWithChildren } from "react"; import { EditInspectionPageParams } from "@/app/(businessModules)/inspection/procedures/[id]/layout"; import { VaccinationConsultationTabNavigationToolbar } from "@/lib/businessModules/travelMedicine/components/vaccinationConsultations/VaccinationConsultationTabNavigationToolbar"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; export default function VaccinationConsultationDetailsLayout({ params, diff --git a/employee-portal/src/app/(businessModules)/travel-medicine/procedure/page.tsx b/employee-portal/src/app/(businessModules)/travel-medicine/procedure/page.tsx index 77201e764c4ca65311dc8a13484029eb0bd2ac0f..34dad89865fd319ad198d8810243c7e419d8fe5f 100644 --- a/employee-portal/src/app/(businessModules)/travel-medicine/procedure/page.tsx +++ b/employee-portal/src/app/(businessModules)/travel-medicine/procedure/page.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { VaccinationConsultationsOverviewTable } from "@/lib/businessModules/travelMedicine/components/vaccinationConsultations/VaccinationConsultationsOverviewTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function VaccinationConsultationsOverviewPage( props: Readonly<{ diff --git a/employee-portal/src/app/(businessModules)/travel-medicine/search-procedure/page.tsx b/employee-portal/src/app/(businessModules)/travel-medicine/search-procedure/page.tsx index 2eb4fc3902ef5c3701226aa731d4ebbe485b49bc..21e326ba56ddb470fb76fb9ada258b8cb0dfe699 100644 --- a/employee-portal/src/app/(businessModules)/travel-medicine/search-procedure/page.tsx +++ b/employee-portal/src/app/(businessModules)/travel-medicine/search-procedure/page.tsx @@ -5,10 +5,11 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { VaccinationConsultationsSearchTable } from "@/lib/businessModules/travelMedicine/components/vaccinationConsultationSearch/VaccinationConsultationsSearchTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function VaccinationConsultationsSearchPage() { return ( diff --git a/employee-portal/src/app/(businessModules)/travel-medicine/vaccines/page.tsx b/employee-portal/src/app/(businessModules)/travel-medicine/vaccines/page.tsx index 4e822eaad8c929d3efe796bbceacf0b8526528b4..4206126f0c300db14b2414d7c6dfcd1802784bf4 100644 --- a/employee-portal/src/app/(businessModules)/travel-medicine/vaccines/page.tsx +++ b/employee-portal/src/app/(businessModules)/travel-medicine/vaccines/page.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { VaccinesTable } from "@/lib/businessModules/travelMedicine/components/vaccines/VaccinesTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function VaccinesOverviewPage() { return ( diff --git a/employee-portal/src/app/error.tsx b/employee-portal/src/app/error.tsx index df1b901384acd806000cf568b68f3cc88a373cbc..160060c7cf18e3174fa37d0c29ef41f45ce8ab45 100644 --- a/employee-portal/src/app/error.tsx +++ b/employee-portal/src/app/error.tsx @@ -5,15 +5,14 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { NextErrorBoundary, NextErrorBoundaryProps, } from "@eshg/lib-portal/components/boundaries/NextErrorBoundary"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; - export default function RootError(props: NextErrorBoundaryProps) { return ( <StickyToolbarLayout diff --git a/employee-portal/src/app/layout.tsx b/employee-portal/src/app/layout.tsx index 1a19c6f5bc47aa2e844a2104700cc40604c9de82..b0db5ba64bfb0524627e06b13347f62c97c0a041 100644 --- a/employee-portal/src/app/layout.tsx +++ b/employee-portal/src/app/layout.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { LayoutConfigProvider } from "@eshg/lib-employee-portal/contexts/layoutConfig"; import { ApiProvider } from "@eshg/lib-portal/api/ApiProvider"; import { HiddenDownloadContainer } from "@eshg/lib-portal/api/files/HiddenDownloadContainer"; import { EnvironmentTypeProvider } from "@eshg/lib-portal/components/EnvironmentTypeProvider"; @@ -14,6 +15,7 @@ import { getNonceFromHeader } from "@eshg/lib-portal/next/contentSecurityPolicyH import { Box } from "@mui/joy"; import { ReactNode } from "react"; +import { LAYOUT_CONFIG } from "@/config/layout"; import { env } from "@/env/server"; import { MainLayout } from "@/lib/baseModule/components/layout/MainLayout"; import { ThemeProvider } from "@/lib/baseModule/theme/ThemeProvider"; @@ -72,28 +74,30 @@ export default function RootLayout({ <EnvironmentTypeProvider environmentType={env.PUBLIC_ENVIRONMENT_TYPE} > - <SnackbarProvider snackbar={EmployeeSnackbar}> - <DrawerProvider> - <ApiProvider configuration={API_CONFIGURATION}> - <ConfirmationDialogProvider - component={EmployeePortalConfirmationDialog} - errorModal={EmployeePortalErrorModal} - > - <ConfirmNavigationProvider> - <QueryBoundary> - <OfflinePasswordPrompt /> - <ServiceWorkerProvider> - <ChatProvider configuration={CHAT_CONFIGURATION}> - <MainLayout>{children}</MainLayout> - </ChatProvider> - {modal} - </ServiceWorkerProvider> - </QueryBoundary> - </ConfirmNavigationProvider> - </ConfirmationDialogProvider> - </ApiProvider> - </DrawerProvider> - </SnackbarProvider> + <LayoutConfigProvider config={LAYOUT_CONFIG}> + <SnackbarProvider snackbar={EmployeeSnackbar}> + <DrawerProvider> + <ApiProvider configuration={API_CONFIGURATION}> + <ConfirmationDialogProvider + component={EmployeePortalConfirmationDialog} + errorModal={EmployeePortalErrorModal} + > + <ConfirmNavigationProvider> + <QueryBoundary> + <OfflinePasswordPrompt /> + <ServiceWorkerProvider> + <ChatProvider configuration={CHAT_CONFIGURATION}> + <MainLayout>{children}</MainLayout> + </ChatProvider> + {modal} + </ServiceWorkerProvider> + </QueryBoundary> + </ConfirmNavigationProvider> + </ConfirmationDialogProvider> + </ApiProvider> + </DrawerProvider> + </SnackbarProvider> + </LayoutConfigProvider> </EnvironmentTypeProvider> <HiddenDownloadContainer /> diff --git a/employee-portal/src/app/loading.tsx b/employee-portal/src/app/loading.tsx index d7d66709c5b7a1297e7a9b20e9540a11b0fb0bbe..b132e0f3c1c99ba4129d09f4d7976524448cc89a 100644 --- a/employee-portal/src/app/loading.tsx +++ b/employee-portal/src/app/loading.tsx @@ -3,10 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { LoadingIndicator } from "@eshg/lib-portal/components/LoadingIndicator"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; - export default function Loading() { return ( <MainContentLayout fullViewportHeight> diff --git a/employee-portal/src/app/playground/addressForm/page.tsx b/employee-portal/src/app/playground/addressForm/page.tsx index 1c807c7a1c79678fc374b292b25c60595abb128d..b6c35bc7001f25575ee7d2c281214f3da68f09bd 100644 --- a/employee-portal/src/app/playground/addressForm/page.tsx +++ b/employee-portal/src/app/playground/addressForm/page.tsx @@ -5,6 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { createFieldNameMapper } from "@eshg/lib-portal/helpers/form"; import { Button, Grid, Stack } from "@mui/joy"; import { Formik } from "formik"; @@ -21,9 +24,6 @@ import { BaseAddressFormInputs, createEmptyAddress, } from "@/lib/shared/components/form/address/helpers"; -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 { Sidebar } from "@/lib/shared/components/sidebar/Sidebar"; import { SidebarActions } from "@/lib/shared/components/sidebar/SidebarActions"; import { SidebarContent } from "@/lib/shared/components/sidebar/SidebarContent"; diff --git a/employee-portal/src/app/playground/alert/page.tsx b/employee-portal/src/app/playground/alert/page.tsx index b4a49f1f03097af215ff02ae2691a92cc82867b5..c71a4d9403757839f22ccb122de278063aebc6e2 100644 --- a/employee-portal/src/app/playground/alert/page.tsx +++ b/employee-portal/src/app/playground/alert/page.tsx @@ -5,6 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { useAlert } from "@eshg/lib-portal/errorHandling/AlertContext"; import { isNonEmptyString } from "@eshg/lib-portal/helpers/guards"; import { @@ -19,10 +22,6 @@ import { } from "@mui/joy"; import { useState } from "react"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; - const DEFAULT_TYPE = "error"; const TYPES = ["error", "warning", "notification"] as const; diff --git a/employee-portal/src/app/playground/appointment-picker/page.tsx b/employee-portal/src/app/playground/appointment-picker/page.tsx index 2c6a764b23a1c1e1e90dd657e8e7fcbf0c870921..1c8ebf959981e915e2a4d555ec439e4ba73ee3f9 100644 --- a/employee-portal/src/app/playground/appointment-picker/page.tsx +++ b/employee-portal/src/app/playground/appointment-picker/page.tsx @@ -5,6 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { Row } from "@eshg/lib-portal/components/Row"; import { SubmitButton } from "@eshg/lib-portal/components/buttons/SubmitButton"; import { FormPlus } from "@eshg/lib-portal/components/form/FormPlus"; @@ -21,10 +24,6 @@ import { addMinutes } from "date-fns"; import { Formik } from "formik"; import { useState } from "react"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; - const now = new Date(); interface Appointment { start: Date; diff --git a/employee-portal/src/app/playground/boundaries/page.tsx b/employee-portal/src/app/playground/boundaries/page.tsx index 52c87e1d115f8601e28066d794e4665d0a138e24..ade193fc1469c59aa747b60e999527526f70e957 100644 --- a/employee-portal/src/app/playground/boundaries/page.tsx +++ b/employee-portal/src/app/playground/boundaries/page.tsx @@ -5,6 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { useHandledMutation } from "@eshg/lib-portal/api/useHandledMutation"; import { BaseModal, @@ -14,9 +17,6 @@ import { QueryBoundary } from "@eshg/lib-portal/components/boundaries/QueryBound import { Button, Stack } from "@mui/joy"; import { OpenModalButton } from "@/lib/shared/components/buttons/OpenModalButton"; -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 { Sidebar, SidebarProps } from "@/lib/shared/components/sidebar/Sidebar"; import { SidebarContent } from "@/lib/shared/components/sidebar/SidebarContent"; diff --git a/employee-portal/src/app/playground/centralFile/acceptUpdate/layout.tsx b/employee-portal/src/app/playground/centralFile/acceptUpdate/layout.tsx index 64d2c0fd7d7364927df1f15e43ac7e55d694d866..cc41767138fb70a383cd1b7f8b6d0c0bd92ef4e3 100644 --- a/employee-portal/src/app/playground/centralFile/acceptUpdate/layout.tsx +++ b/employee-portal/src/app/playground/centralFile/acceptUpdate/layout.tsx @@ -3,13 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; import ProcedureIcon from "@mui/icons-material/TextSnippetOutlined"; import { ReactNode } from "react"; import { centralFilePlaygroundRoutes } from "@/app/playground/centralFile/centralFilePlaygroundRoutes"; import { updateAvailableNavItem } from "@/lib/shared/components/centralFile/constants"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; import { TabNavigationItem } from "@/lib/shared/components/tabNavigation/types"; import { TabNavigationToolbar } from "@/lib/shared/components/tabNavigationToolbar/TabNavigationToolbar"; diff --git a/employee-portal/src/app/playground/centralFile/page.tsx b/employee-portal/src/app/playground/centralFile/page.tsx index 3ffc6c3ef923f6dd19dfb0c80ec418728bdc7b20..13b8b81971f4acf17e2ce20777b53f571b00bdae 100644 --- a/employee-portal/src/app/playground/centralFile/page.tsx +++ b/employee-portal/src/app/playground/centralFile/page.tsx @@ -3,12 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { InternalLink } from "@eshg/lib-portal/components/navigation/InternalLink"; import { centralFilePlaygroundRoutes } from "@/app/playground/centralFile/centralFilePlaygroundRoutes"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function PersonEditFlowsPage() { return ( diff --git a/employee-portal/src/app/playground/charts/page.tsx b/employee-portal/src/app/playground/charts/page.tsx index 1beced5edba9839820580266a6c00abd74a769dc..b7e4cd618068e44e27c5d15b60a13c44122bb6b6 100644 --- a/employee-portal/src/app/playground/charts/page.tsx +++ b/employee-portal/src/app/playground/charts/page.tsx @@ -5,6 +5,7 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { Option, Select, Sheet, Stack, Switch, Typography } from "@mui/joy"; import { ReactNode, useState } from "react"; @@ -24,7 +25,6 @@ import { Histogram } from "@/lib/businessModules/statistics/components/shared/ch import { LineChart } from "@/lib/businessModules/statistics/components/shared/charts/LineChart"; import { PieChart } from "@/lib/businessModules/statistics/components/shared/charts/PieChart"; import { ScatterChart } from "@/lib/businessModules/statistics/components/shared/charts/ScatterChart"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; function PlaygroundChartBox({ title, @@ -68,6 +68,7 @@ export default function PlaygroundChartsPage() { const orientationSwitch = ( <Typography + key="orientationSwitch" component="label" endDecorator={ <Switch @@ -84,6 +85,7 @@ export default function PlaygroundChartsPage() { const groupingSwitch = ( <Typography + key="groupingSwitch" component="label" endDecorator={ <Switch @@ -100,6 +102,7 @@ export default function PlaygroundChartsPage() { const scalingSwitch = ( <Typography + key="scalingSwitch" component="label" endDecorator={ <Switch @@ -116,6 +119,7 @@ export default function PlaygroundChartsPage() { const axisRangeSwitch = ( <Typography + key="axisRangeSwitch" component="label" endDecorator={ <Switch @@ -132,6 +136,7 @@ export default function PlaygroundChartsPage() { const trendLineSwitch = ( <Typography + key="trendLineSwitch" component="label" endDecorator={ <Switch @@ -146,6 +151,7 @@ export default function PlaygroundChartsPage() { const colorSchemeSelect = ( <Typography + key="colorSchemeSelect" component="label" endDecorator={ <Select @@ -164,6 +170,7 @@ export default function PlaygroundChartsPage() { const characteristicParameterSelect = ( <Typography + key="characteristicParameterSelect" component="label" endDecorator={ <Select diff --git a/employee-portal/src/app/playground/chat/chatPlaygroundContent.tsx b/employee-portal/src/app/playground/chat/chatPlaygroundContent.tsx index a9d2c96e189114716db7058cb919d8af347407ab..31e5e4bc5dcffaecbf9a0b54b4dd97bfabe736ff 100644 --- a/employee-portal/src/app/playground/chat/chatPlaygroundContent.tsx +++ b/employee-portal/src/app/playground/chat/chatPlaygroundContent.tsx @@ -22,7 +22,7 @@ import { } from "@/lib/businessModules/chat/matrix/crypto"; import { accessSecretStorage, - deleteBackup, + deleteKeyBackup, } from "@/lib/businessModules/chat/matrix/secretStorage"; import { updateLocalStorageDeviceId } from "@/lib/businessModules/chat/matrix/tokens"; import { useChatClientContext } from "@/lib/businessModules/chat/shared/ChatClientProvider"; @@ -76,8 +76,8 @@ export function ChatPlaygroundContent() { } } - async function deleteKBackup() { - await deleteBackup(matrixClient, backupInfoStatus.backupInfo); + async function handleDeleteKeyBackupClick() { + await deleteKeyBackup(matrixClient, backupInfoStatus.backupInfo); } async function handleDeviceVerify() { @@ -100,7 +100,7 @@ export function ChatPlaygroundContent() { </Stack> <Stack spacing={2} direction="row"> <Button onClick={resetBackup}>Reset backup</Button> - <Button onClick={deleteKBackup}>Delete backup</Button> + <Button onClick={handleDeleteKeyBackupClick}>Delete backup</Button> <Button onClick={handleDeviceVerify}>Is Device verified</Button> </Stack> <Stack spacing={2} direction={{ xxs: "column", sm: "row" }}> diff --git a/employee-portal/src/app/playground/chat/page.tsx b/employee-portal/src/app/playground/chat/page.tsx index 4b36e9af5f6d2b72c3df32cc354aa9c9871293ca..d8149f3fb94a3dedab93f6aaf7c6e32e0b01e4b8 100644 --- a/employee-portal/src/app/playground/chat/page.tsx +++ b/employee-portal/src/app/playground/chat/page.tsx @@ -5,10 +5,11 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; + import { useChat } from "@/lib/businessModules/chat/shared/ChatProvider"; -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 { ChatPlaygroundContent } from "./chatPlaygroundContent"; diff --git a/employee-portal/src/app/playground/designShowcase/page.tsx b/employee-portal/src/app/playground/designShowcase/page.tsx index c5fa28bd0fa38102326bfbc5f93800475d9e4b93..6bce64c65ead4eebacabcecef81afcacd61ece5d 100644 --- a/employee-portal/src/app/playground/designShowcase/page.tsx +++ b/employee-portal/src/app/playground/designShowcase/page.tsx @@ -5,16 +5,15 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { FormPlus } from "@eshg/lib-portal/components/form/FormPlus"; import { InputField } from "@eshg/lib-portal/components/formFields/InputField"; import StarOutlined from "@mui/icons-material/StarOutlined"; import { Sheet, Stack, Typography } from "@mui/joy"; import { Formik } from "formik"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; - export default function DesignShowcasePage() { return ( <StickyToolbarLayout toolbar={<Toolbar title="Design Showcase" />}> diff --git a/employee-portal/src/app/playground/facilityForm/page.tsx b/employee-portal/src/app/playground/facilityForm/page.tsx index 94e0b2d67988f6ecfaf51bf7dca2469ee115e3fc..7e8c55f694abb5ea30eaf9edbeb48f9dd644176f 100644 --- a/employee-portal/src/app/playground/facilityForm/page.tsx +++ b/employee-portal/src/app/playground/facilityForm/page.tsx @@ -6,6 +6,9 @@ "use client"; import { ApiGetReferenceFacilityResponse } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { Button, Stack, Typography } from "@mui/joy"; import { useState } from "react"; @@ -14,9 +17,6 @@ import { Mode, } from "@/lib/shared/components/facilitySidebar/LegacyFacilitySidebar"; import { BaseFacility } from "@/lib/shared/components/facilitySidebar/types"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; enum Sidebar { none, diff --git a/employee-portal/src/app/playground/facilitySidebar/page.tsx b/employee-portal/src/app/playground/facilitySidebar/page.tsx index f028e161a7fd488d4a3d4a315ccbb437ba99d9ec..5b2ecf6e21ae6b8218f9666fbfd77d6a9d651f6f 100644 --- a/employee-portal/src/app/playground/facilitySidebar/page.tsx +++ b/employee-portal/src/app/playground/facilitySidebar/page.tsx @@ -5,29 +5,36 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { SelectField } from "@eshg/lib-portal/components/formFields/SelectField"; import { OptionalFieldValue } from "@eshg/lib-portal/types/form"; import { Button, Card, Stack, Typography } from "@mui/joy"; import { FormikProps } from "formik"; -import { useRef, useState } from "react"; -import { FacilitySidebar } from "@/lib/shared/components/facilitySidebar/FacilitySidebar"; +import { + FacilitySidebar, + FacilitySidebarProps, +} from "@/lib/shared/components/facilitySidebar/FacilitySidebar"; +import { DefaultFacilityFormValues } from "@/lib/shared/components/facilitySidebar/create/FacilityForm"; import { DefaultFacilitySearchForm } from "@/lib/shared/components/facilitySidebar/search/DefaultFacilitySearchForm"; import { FacilitySearchFormValues } from "@/lib/shared/components/facilitySidebar/search/FacilitySearchForm"; -import { SidebarFormHandle } from "@/lib/shared/components/form/SidebarForm"; import { createEmptyAddress } from "@/lib/shared/components/form/address/helpers"; -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 { useSidebarForm } from "@/lib/shared/hooks/useSidebarForm"; +import { + SidebarWithFormRefProps, + useSidebarWithFormRef, +} from "@/lib/shared/hooks/useSidebarWithFormRef"; export default function FacilitySidebarPlaygroundPage() { - const [sidebarState, setSidebarState] = useState("closed"); - - const inactiveRef = useRef<SidebarFormHandle>(null); - - const { closeSidebar, sidebarFormRef, handleClose } = useSidebarForm({ - onClose: () => setSidebarState("closed"), + const facilitySidebar = useSidebarWithFormRef({ + component: ConfiguredDefaultFacilitySidebar, + }); + const extraSearchInputsFacilitySidebar = useSidebarWithFormRef({ + component: ConfiguredExtraSearchInputsFacilitySidebar, + }); + const importFromOsmFacilitySidebar = useSidebarWithFormRef({ + component: ConfiguredImportFromOsmFacilitySidebar, }); return ( @@ -37,128 +44,126 @@ export default function FacilitySidebarPlaygroundPage() { <MainContentLayout> <Stack gap={3}> <Button - onClick={() => setSidebarState("default")} + onClick={() => facilitySidebar.open()} sx={{ width: "fit-content" }} > Default Sidebar </Button> <Button - onClick={() => setSidebarState("extra search inputs")} + onClick={() => extraSearchInputsFacilitySidebar.open()} sx={{ width: "fit-content" }} > Sidebar with extra search inputs </Button> <Button - onClick={() => setSidebarState("import from OSM")} + onClick={() => importFromOsmFacilitySidebar.open()} sx={{ width: "fit-content" }} > WebSuche Import Sidebar </Button> </Stack> - - <FacilitySidebar - open={sidebarState === "default"} - title={"Neuen Vorgang anlegen"} - sidebarFormRef={ - sidebarState === "default" ? sidebarFormRef : inactiveRef - } - onClose={handleClose} - onCreateNew={(values) => { - // eslint-disable-next-line no-console - console.log(values); - closeSidebar(); - return Promise.resolve(); - }} - onSelect={(values) => { - // eslint-disable-next-line no-console - console.log(values); - closeSidebar(); - return Promise.resolve(); - }} - /> - - <FacilitySidebar - open={sidebarState === "extra search inputs"} - title={"Erweiterten Vorgang anlegen"} - sidebarFormRef={ - sidebarState === "extra search inputs" - ? sidebarFormRef - : inactiveRef - } - onClose={handleClose} - onCreateNew={(values) => { - // eslint-disable-next-line no-console - console.log(values); - closeSidebar(); - return Promise.resolve(); - }} - onSelect={(values) => { - // eslint-disable-next-line no-console - console.log(values); - closeSidebar(); - return Promise.resolve(); - }} - initialSearchInputs={{ - name: "", - objectType: "", - }} - searchFormComponent={ExtendedSearchForm} - /> - - <FacilitySidebar - open={sidebarState === "import from OSM"} - title={"OSM Einrichtung Importieren"} - sidebarFormRef={ - sidebarState === "import from OSM" ? sidebarFormRef : inactiveRef - } - onClose={handleClose} - onCreateNew={(values) => { - // eslint-disable-next-line no-console - console.log(values); - closeSidebar(); - return Promise.resolve(); - }} - onSelect={(values) => { - // eslint-disable-next-line no-console - console.log(values); - closeSidebar(); - return Promise.resolve(); - }} - initialSearchInputs={{ - name: "Name der importierten Einrichtung", - }} - getInitialCreateInputs={(inputs) => ({ - ...inputs, - contactAddress: { - ...createEmptyAddress(), - street: "Portlandweg", - houseNumber: "4", - postalCode: "53227", - city: "Bonn", - }, - })} - searchResultHeaderComponent={ - <> - <Card - variant="soft" - color="success" - sx={{ border: "1px solid #A1E8A1" }} - > - <Typography level={"title-md"}> - Name der Importierten Einrichtung - </Typography> - <Typography>Portlandweg 4, 53227 Bonn</Typography> - </Card> - Ergebnisse: - </> - } - mode={"import"} - /> </MainContentLayout> </StickyToolbarLayout> ); } +function ConfiguredDefaultFacilitySidebar(props: SidebarWithFormRefProps) { + const facilitySidebarProps: FacilitySidebarProps<DefaultFacilityFormValues> = + { + title: "Neuen Vorgang anlegen", + onCreateNew: (values) => { + // eslint-disable-next-line no-console + console.log(values); + return Promise.resolve(); + }, + onSelect: (values) => { + // eslint-disable-next-line no-console + console.log(values); + return Promise.resolve(); + }, + ...props, + }; + + return <FacilitySidebar {...facilitySidebarProps} />; +} + +function ConfiguredExtraSearchInputsFacilitySidebar( + props: SidebarWithFormRefProps, +) { + const facilitySidebarProps: FacilitySidebarProps<ExtendedSearchFormValues> = { + title: "Erweiterten Vorgang anlegen", + onCreateNew: (values) => { + // eslint-disable-next-line no-console + console.log(values); + return Promise.resolve(); + }, + onSelect: (values) => { + // eslint-disable-next-line no-console + console.log(values); + return Promise.resolve(); + }, + initialSearchInputs: { + name: "", + objectType: "", + }, + searchFormComponent: ExtendedSearchForm, + ...props, + }; + + return <FacilitySidebar {...facilitySidebarProps} />; +} + +function ConfiguredImportFromOsmFacilitySidebar( + props: SidebarWithFormRefProps, +) { + const facilitySidebarProps: FacilitySidebarProps<DefaultFacilityFormValues> = + { + title: "OSM Einrichtung Importieren", + onCreateNew: (values) => { + // eslint-disable-next-line no-console + console.log(values); + return Promise.resolve(); + }, + onSelect: (values) => { + // eslint-disable-next-line no-console + console.log(values); + return Promise.resolve(); + }, + initialSearchInputs: { + name: "Name der importierten Einrichtung", + }, + getInitialCreateInputs: (inputs) => ({ + ...inputs, + contactAddress: { + ...createEmptyAddress(), + street: "Portlandweg", + houseNumber: "4", + postalCode: "53227", + city: "Bonn", + }, + }), + searchResultHeaderComponent: ( + <> + <Card + variant="soft" + color="success" + sx={{ border: "1px solid #A1E8A1" }} + > + <Typography level={"title-md"}> + Name der Importierten Einrichtung + </Typography> + <Typography>Portlandweg 4, 53227 Bonn</Typography> + </Card> + Ergebnisse: + </> + ), + mode: "import", + ...props, + }; + + return <FacilitySidebar {...facilitySidebarProps} />; +} + interface ExtendedSearchFormValues extends FacilitySearchFormValues { objectType: OptionalFieldValue<"SCHOOL" | "HOSPITAL">; } diff --git a/employee-portal/src/app/playground/filter-settings/page.tsx b/employee-portal/src/app/playground/filter-settings/page.tsx index 9f36e574fb3e8d505f366d2216b204cd3f2f0217..6a8f17904d769515a4422a49978aa06ec422446c 100644 --- a/employee-portal/src/app/playground/filter-settings/page.tsx +++ b/employee-portal/src/app/playground/filter-settings/page.tsx @@ -5,6 +5,7 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { Button, Switch, Typography } from "@mui/joy"; import { useState } from "react"; @@ -20,7 +21,6 @@ import { NumberFilterNumericComparison, } from "@/lib/shared/components/filterSettings/models/NumberFilter"; import { useFilterSettings } from "@/lib/shared/components/filterSettings/useFilterSettings"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; import { Sidebar } from "@/lib/shared/components/sidebar/Sidebar"; import { SidebarContent } from "@/lib/shared/components/sidebar/SidebarContent"; import { DataTable } from "@/lib/shared/components/table/DataTable"; diff --git a/employee-portal/src/app/playground/filter-settings/unmanaged/page.tsx b/employee-portal/src/app/playground/filter-settings/unmanaged/page.tsx index 58aa692fe6f78f7dc74ee3c8f4ce207dda235fd2..cbcac00e64c2dca28707037b9b07fe573ed04405 100644 --- a/employee-portal/src/app/playground/filter-settings/unmanaged/page.tsx +++ b/employee-portal/src/app/playground/filter-settings/unmanaged/page.tsx @@ -5,6 +5,7 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { isDateString } from "@eshg/lib-portal/helpers/dateTime"; import { Button, @@ -21,7 +22,6 @@ import { FilterButton } from "@/lib/shared/components/buttons/FilterButton"; import { ActiveFilter } from "@/lib/shared/components/filterSettings/ActiveFilter"; import { FilterSettingsContent } from "@/lib/shared/components/filterSettings/FilterSettingsContent"; import { FilterSettingsSheet } from "@/lib/shared/components/filterSettings/FilterSettingsSheet"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; import { DataTable } from "@/lib/shared/components/table/DataTable"; import { TablePage } from "@/lib/shared/components/table/TablePage"; import { TableSheet } from "@/lib/shared/components/table/TableSheet"; diff --git a/employee-portal/src/app/playground/formPlus/page.tsx b/employee-portal/src/app/playground/formPlus/page.tsx index e70d3929a0419636e54b6f046fa61a13acddda8a..4050457769af2160338114ab39209e299f05e1e9 100644 --- a/employee-portal/src/app/playground/formPlus/page.tsx +++ b/employee-portal/src/app/playground/formPlus/page.tsx @@ -5,16 +5,15 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { FormPlus } from "@eshg/lib-portal/components/form/FormPlus"; import { InputField } from "@eshg/lib-portal/components/formFields/InputField"; import { useSnackbar } from "@eshg/lib-portal/components/snackbar/SnackbarProvider"; import { Button, CircularProgress, Grid, Stack } from "@mui/joy"; import { Formik } from "formik"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; - export default function PlaygroundFormPlusPage() { const snackbar = useSnackbar(); diff --git a/employee-portal/src/app/playground/image-compressor/page.tsx b/employee-portal/src/app/playground/image-compressor/page.tsx index 2ade35bdc941b2f885fd2312e9b67169f5871f5f..9002bbe6d8989305c23f55e5bbb2397f252ac9b8 100644 --- a/employee-portal/src/app/playground/image-compressor/page.tsx +++ b/employee-portal/src/app/playground/image-compressor/page.tsx @@ -5,6 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { FormPlus } from "@eshg/lib-portal/components/form/FormPlus"; import { FileLike } from "@eshg/lib-portal/components/formFields/file/validators"; import { formatFileSize } from "@eshg/lib-portal/helpers/file"; @@ -14,9 +17,6 @@ import { Formik } from "formik"; import { useEffect, useState } from "react"; import { FileField } from "@/lib/shared/components/formFields/file/FileField"; -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 { compressImage } from "@/lib/shared/helpers/imageCompressor"; const StyledImage = styled("img")({ width: "100%" }); diff --git a/employee-portal/src/app/playground/layout/regular/page.tsx b/employee-portal/src/app/playground/layout/regular/page.tsx index f01a75fda235596d585a2b53c388d5ba3ab78439..656ecd94cd31de6a0729cfa826c20bf734c4e091 100644 --- a/employee-portal/src/app/playground/layout/regular/page.tsx +++ b/employee-portal/src/app/playground/layout/regular/page.tsx @@ -5,6 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { Sheet, Slider, Switch, Typography } from "@mui/joy"; import { createColumnHelper } from "@tanstack/react-table"; import { useState } from "react"; @@ -12,9 +15,6 @@ import { doNothing } from "remeda"; import { FilterSettings } from "@/lib/shared/components/filterSettings/FilterSettings"; import { FilterSettingsSheet } from "@/lib/shared/components/filterSettings/FilterSettingsSheet"; -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 { DataTable } from "@/lib/shared/components/table/DataTable"; import { TablePage } from "@/lib/shared/components/table/TablePage"; import { TableSheet } from "@/lib/shared/components/table/TableSheet"; diff --git a/employee-portal/src/app/playground/layout/toolbar/page.tsx b/employee-portal/src/app/playground/layout/toolbar/page.tsx index b7575f1b4e734b9b910ed7cd7314534aaaf2aafc..16c0ae6dc6d68bd4ceeeda93b44ddaa2cdad9324 100644 --- a/employee-portal/src/app/playground/layout/toolbar/page.tsx +++ b/employee-portal/src/app/playground/layout/toolbar/page.tsx @@ -5,13 +5,13 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { BottomToolbar } from "@eshg/lib-employee-portal/components/toolbar/BottomToolbar"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { Sheet, Slider, Switch, Typography } from "@mui/joy"; import { useState } from "react"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; - export default function PlaygroundStickyToolbarLayoutPage() { const [fullViewportHeight, setFullViewportHeight] = useState(true); const [itemCount, setItemCount] = useState(15); @@ -49,6 +49,7 @@ export default function PlaygroundStickyToolbarLayoutPage() { return ( <StickyToolbarLayout toolbar={<Toolbar title="Playground - Layout with sticky toolbar" />} + bottomToolbar={<BottomToolbar>An optional bottom toolbar</BottomToolbar>} > <MainContentLayout fullViewportHeight={fullViewportHeight} gap={2}> {controls} diff --git a/employee-portal/src/app/playground/offline-password/page.tsx b/employee-portal/src/app/playground/offline-password/page.tsx index 7443d6468f51e07aebcbc45af3f89d30c6793e03..611318f0e510e76b6d6a15dbdd1e80c4cec6493e 100644 --- a/employee-portal/src/app/playground/offline-password/page.tsx +++ b/employee-portal/src/app/playground/offline-password/page.tsx @@ -5,14 +5,14 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { Button, Stack, Switch, Typography } from "@mui/joy"; import { useState } from "react"; import { OfflineExistingPasswordDialog } from "@/lib/businessModules/inspection/shared/offline/password/OfflineExistingPasswordDialog"; import { OfflineNewPasswordDialog } from "@/lib/businessModules/inspection/shared/offline/password/OfflineNewPasswordDialog"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function PlaygroundOfflinePasswordPage() { const [openNewPasswordDialog, setOpenNewPasswordDialog] = useState(false); diff --git a/employee-portal/src/app/playground/page.tsx b/employee-portal/src/app/playground/page.tsx index 65160e2ea4d8925e62ed6a32a48868ec92d877c4..43f811010502c4425bf45b5b832b5339bcac573d 100644 --- a/employee-portal/src/app/playground/page.tsx +++ b/employee-portal/src/app/playground/page.tsx @@ -3,12 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { InternalLink } from "@eshg/lib-portal/components/navigation/InternalLink"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; - export default function PlaygroundIndexPage() { return ( <StickyToolbarLayout toolbar={<Toolbar title="Playground" />}> @@ -107,6 +106,11 @@ export default function PlaygroundIndexPage() { <li> <InternalLink href="/playground/sidebar">Sidebar</InternalLink> </li> + <li> + <InternalLink href="/playground/sideNavigation"> + SideNavigation + </InternalLink> + </li> <li> <InternalLink href="/playground/alert">Alert</InternalLink> </li> diff --git a/employee-portal/src/app/playground/personSidebar/page.tsx b/employee-portal/src/app/playground/personSidebar/page.tsx index 9682e1f78add936d8b34203edab9429d4dab580b..23bea8a6cc2013cf8a88d2051c9f38ed7f329395 100644 --- a/employee-portal/src/app/playground/personSidebar/page.tsx +++ b/employee-portal/src/app/playground/personSidebar/page.tsx @@ -5,19 +5,20 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { SelectField } from "@eshg/lib-portal/components/formFields/SelectField"; import { useSnackbar } from "@eshg/lib-portal/components/snackbar/SnackbarProvider"; import { OptionalFieldValue } from "@eshg/lib-portal/types/form"; import { ApiSchoolEntryProcedureType } from "@eshg/school-entry-api"; import { Button, Stack } from "@mui/joy"; -import { useRef, useState } from "react"; import { PROCEDURE_TYPE_OPTIONS_EXCLUDING_DRAFT } from "@/lib/businessModules/schoolEntry/features/procedures/options"; -import { SidebarFormHandle } from "@/lib/shared/components/form/SidebarForm"; -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 { PersonSidebar } from "@/lib/shared/components/personSidebar/PersonSidebar"; +import { + PersonSidebar, + PersonSidebarProps, +} from "@/lib/shared/components/personSidebar/PersonSidebar"; import { DefaultPersonFormValues } from "@/lib/shared/components/personSidebar/form/DefaultPersonForm"; import { DefaultSearchPersonForm, @@ -28,104 +29,95 @@ import { SearchPersonFormProps, SearchPersonFormValues, } from "@/lib/shared/components/personSidebar/search/SearchPersonSidebar"; -import { Sidebar } from "@/lib/shared/components/sidebar/Sidebar"; -import { useConfirmationDialog } from "@/lib/shared/hooks/useConfirmationDialog"; +import { + SidebarWithFormRefProps, + useSidebarWithFormRef, +} from "@/lib/shared/hooks/useSidebarWithFormRef"; export default function PersonSidebarPage() { - const [sidebarOpen, setSidebarOpen] = useState("none"); - const { openCancelDialog } = useConfirmationDialog(); - const snackbar = useSnackbar(); - - const sidebarFormRef = useRef<SidebarFormHandle>(null); - - function closeSidebar() { - setSidebarOpen("none"); - } - - function handleClose() { - if (sidebarFormRef.current?.dirty) { - openCancelDialog({ - onConfirm: closeSidebar, - }); - } else { - closeSidebar(); - } - } + const personSidebar = useSidebarWithFormRef({ + component: ConfiguredDefaultPersonSidebar, + }); + const esuPersonSidebar = useSidebarWithFormRef({ + component: ConfiguredEsuPersonSidebar, + }); return ( <StickyToolbarLayout toolbar={<Toolbar title="Person Sidebar" />}> <MainContentLayout fullViewportHeight> <Stack gap={3}> - <Button onClick={() => setSidebarOpen("default")}> + <Button onClick={() => personSidebar.open()}> Open Default Sidebar </Button> - <Button onClick={() => setSidebarOpen("esu")}> + <Button onClick={() => esuPersonSidebar.open()}> Open ESU Sidebar </Button> </Stack> - - <Sidebar open={sidebarOpen !== "none"} onClose={handleClose}> - {sidebarOpen === "default" && ( - <PersonSidebar - onCancel={handleClose} - onSelect={(values) => { - // eslint-disable-next-line no-console - console.log(values); - snackbar.confirmation("Vorgang wurde angelegt"); - closeSidebar(); - return Promise.resolve(); - }} - onCreate={(values) => { - // eslint-disable-next-line no-console - console.log("Default Form Result", values); - snackbar.confirmation("Vorgang wurde angelegt"); - closeSidebar(); - return Promise.resolve(); - }} - sidebarFormRef={sidebarFormRef} - title={"Vorgang anlegen"} - submitLabel={"Fertig"} - addressRequired - /> - )} - {sidebarOpen === "esu" && ( - <PersonSidebar<EsuPersonSearchFormValues, EsuPersonCreateFormValues> - title={"Vorgang anlegen"} - submitLabel={"Vorgang anlegen"} - sidebarFormRef={sidebarFormRef} - onCancel={handleClose} - onSelect={(values) => { - // eslint-disable-next-line no-console - console.log(values); - snackbar.confirmation("Vorgang wurde angelegt"); - closeSidebar(); - return Promise.resolve(); - }} - onCreate={({ searchInputs, createInputs }) => { - // eslint-disable-next-line no-console - console.log("ESU Form Result", { - // inputs on the search step - searchInputs, - // inputs on the create / edit step - createInputs, - }); - snackbar.confirmation("Vorgang wurde angelegt"); - closeSidebar(); - return Promise.resolve(); - }} - searchFormComponent={EsuPersonSearchForm} - initialSearchState={{ - ...defaultSearchPersonValues(), - type: "REGULAR_EXAMINATION", - }} - /> - )} - </Sidebar> </MainContentLayout> </StickyToolbarLayout> ); } +function ConfiguredDefaultPersonSidebar(props: SidebarWithFormRefProps) { + const snackbar = useSnackbar(); + const personSidebarProps: PersonSidebarProps = { + onSelect: (values) => { + // eslint-disable-next-line no-console + console.log(values); + snackbar.confirmation("Vorgang wurde angelegt"); + return Promise.resolve(); + }, + onCreate: (values) => { + // eslint-disable-next-line no-console + console.log("Default Form Result", values); + snackbar.confirmation("Vorgang wurde angelegt"); + return Promise.resolve(); + }, + title: "Vorgang anlegen", + submitLabel: "Fertig", + addressRequired: true, + ...props, + }; + + return <PersonSidebar {...personSidebarProps} />; +} + +function ConfiguredEsuPersonSidebar(props: SidebarWithFormRefProps) { + const snackbar = useSnackbar(); + const personSidebarProps: PersonSidebarProps< + EsuPersonSearchFormValues, + EsuPersonCreateFormValues + > = { + title: "Vorgang anlegen", + submitLabel: "Vorgang anlegen", + onSelect: (values) => { + // eslint-disable-next-line no-console + console.log(values); + snackbar.confirmation("Vorgang wurde angelegt"); + return Promise.resolve(); + }, + onCreate: ({ searchInputs, createInputs }) => { + // eslint-disable-next-line no-console + console.log("ESU Form Result", { + // inputs on the search step + searchInputs, + // inputs on the create / edit step + createInputs, + }); + snackbar.confirmation("Vorgang wurde angelegt"); + return Promise.resolve(); + }, + searchFormComponent: EsuPersonSearchForm, + initialSearchState: { + ...defaultSearchPersonValues(), + type: "REGULAR_EXAMINATION", + }, + ...props, + }; + + return <PersonSidebar {...personSidebarProps} />; +} + interface EsuPersonCreateFormValues extends DefaultPersonFormValues { type: OptionalFieldValue<ApiSchoolEntryProcedureType>; } diff --git a/employee-portal/src/app/playground/prototypes/dental-examination/page.tsx b/employee-portal/src/app/playground/prototypes/dental-examination/page.tsx index b4a1a88d5526364a3dd5379173d15cba0bd8e630..3447f352d8c987a0e62b2aaf2f81198023facca7 100644 --- a/employee-portal/src/app/playground/prototypes/dental-examination/page.tsx +++ b/employee-portal/src/app/playground/prototypes/dental-examination/page.tsx @@ -5,6 +5,8 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; import { ButtonLink } from "@eshg/lib-portal/components/buttons/ButtonLink"; import { DocumentScanner, @@ -37,9 +39,7 @@ import { ButtonBar } from "@/lib/shared/components/buttons/ButtonBar"; import { ContentPanelTitle } from "@/lib/shared/components/contentPanel/ContentPanelTitle"; import { DrawerProps } from "@/lib/shared/components/drawer/drawerContext"; import { useSidebar } from "@/lib/shared/components/drawer/useSidebar"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; import { PersonToolbarHeader } from "@/lib/shared/components/layout/PersonToolbarHeader"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; import { SidebarActions } from "@/lib/shared/components/sidebar/SidebarActions"; import { SidebarContent } from "@/lib/shared/components/sidebar/SidebarContent"; import { TabNavigationToolbar } from "@/lib/shared/components/tabNavigationToolbar/TabNavigationToolbar"; diff --git a/employee-portal/src/app/playground/searchable-groups/page.tsx b/employee-portal/src/app/playground/searchable-groups/page.tsx index a2d0170f43b76086ac1b95313190eceafe82073d..31220c35ba9b2214d53267721a400b59a77a21e4 100644 --- a/employee-portal/src/app/playground/searchable-groups/page.tsx +++ b/employee-portal/src/app/playground/searchable-groups/page.tsx @@ -5,6 +5,7 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { SubmitButton } from "@eshg/lib-portal/components/buttons/SubmitButton"; import { FormPlus } from "@eshg/lib-portal/components/form/FormPlus"; import { Sheet, Stack } from "@mui/joy"; @@ -15,7 +16,6 @@ import { SearchableGroups, } from "@/lib/shared/components/SearchableGroups"; import { CheckboxField } from "@/lib/shared/components/formFields/CheckboxField"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; export default function PlaygroundSearchableGroupsPage() { const groups = [ diff --git a/employee-portal/src/app/playground/sideNavigation/page.tsx b/employee-portal/src/app/playground/sideNavigation/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f22d05213274501578de4d1a03b773f3569bee1a --- /dev/null +++ b/employee-portal/src/app/playground/sideNavigation/page.tsx @@ -0,0 +1,126 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +"use client"; + +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; +import { noCheck } from "@eshg/lib-employee-portal/helpers/accessControl"; +import { + AcUnitOutlined, + AppsOutlined, + ChatOutlined, + InsertEmoticonOutlined, + LightOutlined, + WavingHandOutlined, +} from "@mui/icons-material"; +import { Chip, Stack } from "@mui/joy"; + +import { NavigationListCollapsed } from "@/lib/baseModule/components/layout/sideNavigation/lists/NavigationListCollapsed"; +import { NavigationListExpanded } from "@/lib/baseModule/components/layout/sideNavigation/lists/NavigationListExpanded"; +import { SideNavItemGroups } from "@/lib/baseModule/components/layout/sideNavigation/types"; + +const itemGroups: SideNavItemGroups = { + dashboardItem: [ + { + name: "Single Item", + href: "#", + decorator: <LightOutlined />, + accessCheck: noCheck(), + }, + ], + businessItems: [ + { + name: "Dashboard", + href: "#", + decorator: <AppsOutlined />, + accessCheck: noCheck(), + }, + { + name: "Selected", + href: "/playground/sideNavigation", + decorator: <WavingHandOutlined />, + accessCheck: noCheck(), + }, + { + name: "Chat", + href: "#", + decorator: <ChatOutlined />, + accessCheck: noCheck(), + chip: <Chip color="primary">15</Chip>, + }, + { + name: "Rechtsschutzversicherungsgesellschaften", + href: "#", + decorator: <AcUnitOutlined />, + accessCheck: noCheck(), + }, + ], + baseItems: [ + { + name: "Hauptmenü", + decorator: <InsertEmoticonOutlined />, + subItems: [ + { name: "Benutzer", href: "#", accessCheck: noCheck() }, + { name: "Kalender", href: "#", accessCheck: noCheck() }, + { name: "Ressourcen", href: "#", accessCheck: noCheck() }, + { name: "Zahnärztlicher Dienst", href: "#", accessCheck: noCheck() }, + ], + }, + { + name: "Selected menu", + href: "/playground/sideNavigation", + decorator: <WavingHandOutlined />, + subItems: [ + { + name: "Playground", + href: "/playground/sideNavigation", + accessCheck: noCheck(), + }, + { name: "Other", href: "#", accessCheck: noCheck() }, + ], + }, + { + name: "Kraftfahrzeug-Haftpflichtversicherung", + decorator: <LightOutlined />, + subItems: [{ name: "Item", href: "#", accessCheck: noCheck() }], + }, + { + name: "Noch ein Item", + decorator: <LightOutlined />, + error: "error message", + subItems: [{ name: "Item", href: "#", accessCheck: noCheck() }], + }, + ], +}; + +export default function SideNavigationPlaygroundPage() { + return ( + <StickyToolbarLayout + toolbar={<Toolbar title="SideNavigation" backHref="/playground" />} + > + <MainContentLayout> + <Stack direction="row" spacing={2}> + <NavigationListExpanded + isLoading={false} + showCollapseButton={true} + onCollapse={() => { + alert("Collapse"); + }} + itemGroups={itemGroups} + /> + + <NavigationListCollapsed + onExpand={() => { + alert("Expand"); + }} + itemGroups={itemGroups} + /> + </Stack> + </MainContentLayout> + </StickyToolbarLayout> + ); +} diff --git a/employee-portal/src/app/playground/sidebar/page.tsx b/employee-portal/src/app/playground/sidebar/page.tsx index 8e13316fc74bb00376114c8a1cb7c8d2f796cb88..b7423dfafc39fce8dab8026f3e966c119c223108 100644 --- a/employee-portal/src/app/playground/sidebar/page.tsx +++ b/employee-portal/src/app/playground/sidebar/page.tsx @@ -5,6 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { InputField } from "@eshg/lib-portal/components/formFields/InputField"; import { Button, Grid, Input, Typography } from "@mui/joy"; import { useSuspenseQuery } from "@tanstack/react-query"; @@ -15,9 +18,6 @@ import { ButtonBar } from "@/lib/shared/components/buttons/ButtonBar"; import { DrawerProps } from "@/lib/shared/components/drawer/drawerContext"; import { useSidebar } from "@/lib/shared/components/drawer/useSidebar"; import { SidebarForm } from "@/lib/shared/components/form/SidebarForm"; -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 { SidebarActions } from "@/lib/shared/components/sidebar/SidebarActions"; import { SidebarContent } from "@/lib/shared/components/sidebar/SidebarContent"; import { diff --git a/employee-portal/src/app/playground/snackbar/page.tsx b/employee-portal/src/app/playground/snackbar/page.tsx index 1ff59ab5b55b46f83c5a6acae84d7991921a1b53..df30583a4fdddad853837086ac93c929fb44b345 100644 --- a/employee-portal/src/app/playground/snackbar/page.tsx +++ b/employee-portal/src/app/playground/snackbar/page.tsx @@ -5,6 +5,9 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { useSnackbar } from "@eshg/lib-portal/components/snackbar/SnackbarProvider"; import { isNonEmptyString } from "@eshg/lib-portal/helpers/guards"; import { @@ -19,10 +22,6 @@ import { } from "@mui/joy"; import { useState } from "react"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; - const DEFAULT_TYPE = "confirmation"; const TYPES = ["confirmation", "error", "notification"] as const; diff --git a/employee-portal/src/app/playground/teeth/page.tsx b/employee-portal/src/app/playground/teeth/page.tsx index 263a214293539ccb00ebe91f9cd35690b5af0787..2b79474b3f3be90b7b2f79ee1dcc91fb9c5b3097 100644 --- a/employee-portal/src/app/playground/teeth/page.tsx +++ b/employee-portal/src/app/playground/teeth/page.tsx @@ -3,6 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { Grid, Typography } from "@mui/joy"; import { @@ -11,9 +14,6 @@ import { Molar, Premolar, } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/Teeth"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function TeethPlaygroundPage() { return ( @@ -27,37 +27,61 @@ export default function TeethPlaygroundPage() { </Grid> <Grid xxs={1}> - <Incisor variant="upperJaw" isPrimaryTooth /> + <Incisor + variant="upperJaw" + isPrimaryTooth + toothContext={{ quadrantNumber: "Q1", toothIndex: 7 }} + /> </Grid> <Grid xxs={1}> <Incisor variant="upperJaw" isPrimaryTooth hasPreviousExaminationResult + toothContext={{ quadrantNumber: "Q1", toothIndex: 6 }} /> </Grid> <Grid xxs={1}> - <Incisor variant="upperJaw" /> + <Incisor + variant="upperJaw" + toothContext={{ quadrantNumber: "Q2", toothIndex: 0 }} + /> </Grid> <Grid xxs={1}> - <Incisor variant="upperJaw" hasPreviousExaminationResult /> + <Incisor + variant="upperJaw" + hasPreviousExaminationResult + toothContext={{ quadrantNumber: "Q2", toothIndex: 1 }} + /> </Grid> <Grid xxs={1}> - <Incisor variant="lowerJaw" isPrimaryTooth /> + <Incisor + variant="lowerJaw" + isPrimaryTooth + toothContext={{ quadrantNumber: "Q4", toothIndex: 6 }} + /> </Grid> <Grid xxs={1}> <Incisor variant="lowerJaw" isPrimaryTooth hasPreviousExaminationResult + toothContext={{ quadrantNumber: "Q4", toothIndex: 7 }} /> </Grid> <Grid xxs={1}> - <Incisor variant="lowerJaw" /> + <Incisor + variant="lowerJaw" + toothContext={{ quadrantNumber: "Q3", toothIndex: 0 }} + /> </Grid> <Grid xxs={1}> - <Incisor variant="lowerJaw" hasPreviousExaminationResult /> + <Incisor + variant="lowerJaw" + hasPreviousExaminationResult + toothContext={{ quadrantNumber: "Q3", toothIndex: 1 }} + /> </Grid> <Grid xxs={12}> @@ -65,37 +89,61 @@ export default function TeethPlaygroundPage() { </Grid> <Grid xxs={1}> - <Cuspid variant="upperJaw" isPrimaryTooth /> + <Cuspid + variant="upperJaw" + isPrimaryTooth + toothContext={{ quadrantNumber: "Q1", toothIndex: 5 }} + /> </Grid> <Grid xxs={1}> <Cuspid variant="upperJaw" isPrimaryTooth hasPreviousExaminationResult + toothContext={{ quadrantNumber: "Q1", toothIndex: 4 }} /> </Grid> <Grid xxs={1}> - <Cuspid variant="upperJaw" /> + <Cuspid + variant="upperJaw" + toothContext={{ quadrantNumber: "Q2", toothIndex: 2 }} + /> </Grid> <Grid xxs={1}> - <Cuspid variant="upperJaw" hasPreviousExaminationResult /> + <Cuspid + variant="upperJaw" + hasPreviousExaminationResult + toothContext={{ quadrantNumber: "Q2", toothIndex: 3 }} + /> </Grid> <Grid xxs={1}> - <Cuspid variant="lowerJaw" isPrimaryTooth /> + <Cuspid + variant="lowerJaw" + isPrimaryTooth + toothContext={{ quadrantNumber: "Q4", toothIndex: 5 }} + /> </Grid> <Grid xxs={1}> <Cuspid variant="lowerJaw" isPrimaryTooth hasPreviousExaminationResult + toothContext={{ quadrantNumber: "Q4", toothIndex: 4 }} /> </Grid> <Grid xxs={1}> - <Cuspid variant="lowerJaw" /> + <Cuspid + variant="lowerJaw" + toothContext={{ quadrantNumber: "Q3", toothIndex: 2 }} + /> </Grid> <Grid xxs={1}> - <Cuspid variant="lowerJaw" hasPreviousExaminationResult /> + <Cuspid + variant="lowerJaw" + hasPreviousExaminationResult + toothContext={{ quadrantNumber: "Q3", toothIndex: 3 }} + /> </Grid> <Grid xxs={12}> @@ -103,37 +151,61 @@ export default function TeethPlaygroundPage() { </Grid> <Grid xxs={1}> - <Premolar variant="upperJaw" isPrimaryTooth /> + <Premolar + variant="upperJaw" + isPrimaryTooth + toothContext={{ quadrantNumber: "Q1", toothIndex: 3 }} + /> </Grid> <Grid xxs={1}> <Premolar variant="upperJaw" isPrimaryTooth hasPreviousExaminationResult + toothContext={{ quadrantNumber: "Q1", toothIndex: 2 }} /> </Grid> <Grid xxs={1}> - <Premolar variant="upperJaw" /> + <Premolar + variant="upperJaw" + toothContext={{ quadrantNumber: "Q2", toothIndex: 4 }} + /> </Grid> <Grid xxs={1}> - <Premolar variant="upperJaw" hasPreviousExaminationResult /> + <Premolar + variant="upperJaw" + hasPreviousExaminationResult + toothContext={{ quadrantNumber: "Q2", toothIndex: 5 }} + /> </Grid> <Grid xxs={1}> - <Premolar variant="lowerJaw" isPrimaryTooth /> + <Premolar + variant="lowerJaw" + isPrimaryTooth + toothContext={{ quadrantNumber: "Q4", toothIndex: 3 }} + /> </Grid> <Grid xxs={1}> <Premolar variant="lowerJaw" isPrimaryTooth hasPreviousExaminationResult + toothContext={{ quadrantNumber: "Q4", toothIndex: 2 }} /> </Grid> <Grid xxs={1}> - <Premolar variant="lowerJaw" /> + <Premolar + variant="lowerJaw" + toothContext={{ quadrantNumber: "Q3", toothIndex: 4 }} + /> </Grid> <Grid xxs={1}> - <Premolar variant="lowerJaw" hasPreviousExaminationResult /> + <Premolar + variant="lowerJaw" + hasPreviousExaminationResult + toothContext={{ quadrantNumber: "Q3", toothIndex: 5 }} + /> </Grid> <Grid xxs={12}> @@ -141,37 +213,61 @@ export default function TeethPlaygroundPage() { </Grid> <Grid xxs={1}> - <Molar variant="upperJaw" isPrimaryTooth /> + <Molar + variant="upperJaw" + isPrimaryTooth + toothContext={{ quadrantNumber: "Q1", toothIndex: 1 }} + /> </Grid> <Grid xxs={1}> <Molar variant="upperJaw" isPrimaryTooth hasPreviousExaminationResult + toothContext={{ quadrantNumber: "Q1", toothIndex: 0 }} /> </Grid> <Grid xxs={1}> - <Molar variant="upperJaw" /> + <Molar + variant="upperJaw" + toothContext={{ quadrantNumber: "Q2", toothIndex: 6 }} + /> </Grid> <Grid xxs={1}> - <Molar variant="upperJaw" hasPreviousExaminationResult /> + <Molar + variant="upperJaw" + hasPreviousExaminationResult + toothContext={{ quadrantNumber: "Q2", toothIndex: 7 }} + /> </Grid> <Grid xxs={1}> - <Molar variant="lowerJaw" isPrimaryTooth /> + <Molar + variant="lowerJaw" + isPrimaryTooth + toothContext={{ quadrantNumber: "Q4", toothIndex: 1 }} + /> </Grid> <Grid xxs={1}> <Molar variant="lowerJaw" isPrimaryTooth hasPreviousExaminationResult + toothContext={{ quadrantNumber: "Q4", toothIndex: 0 }} /> </Grid> <Grid xxs={1}> - <Molar variant="lowerJaw" /> + <Molar + variant="lowerJaw" + toothContext={{ quadrantNumber: "Q3", toothIndex: 6 }} + /> </Grid> <Grid xxs={1}> - <Molar variant="lowerJaw" hasPreviousExaminationResult /> + <Molar + variant="lowerJaw" + hasPreviousExaminationResult + toothContext={{ quadrantNumber: "Q3", toothIndex: 7 }} + /> </Grid> </Grid> </MainContentLayout> diff --git a/employee-portal/src/app/~offline/page.tsx b/employee-portal/src/app/~offline/page.tsx index 02483754b71257a1b117f59ec22c0f207ddcbdc4..9d2d7c8a66c0686de83a7af055d52c00955af91d 100644 --- a/employee-portal/src/app/~offline/page.tsx +++ b/employee-portal/src/app/~offline/page.tsx @@ -3,13 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { NavigationLink } from "@eshg/lib-portal/components/navigation/NavigationLink"; import { routes as inspectionRoutes } from "@/lib/businessModules/inspection/shared/routes"; import { ContentPanel } from "@/lib/shared/components/contentPanel/ContentPanel"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export default function Offline() { return ( diff --git a/employee-portal/src/config/layout.ts b/employee-portal/src/config/layout.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9c6ef61c6ebe50e9c93a934cdce8a5a213c07a2 --- /dev/null +++ b/employee-portal/src/config/layout.ts @@ -0,0 +1,12 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { LayoutConfig } from "@eshg/lib-employee-portal/contexts/layoutConfig"; + +export const LAYOUT_CONFIG: LayoutConfig = { + appBarHeightMobile: "3.5rem", // 56px + appBarHeightDesktop: "4.5rem", // 72px + simpleToolbarHeight: "3.625rem", //58px +}; diff --git a/employee-portal/src/env/client.js b/employee-portal/src/env/client.js index e2888407fdb5ae51290a8b8000e398f397d26e29..a30b7ccc0c8c2d0ce1972fb76f980af5af0f736c 100644 --- a/employee-portal/src/env/client.js +++ b/employee-portal/src/env/client.js @@ -5,6 +5,7 @@ /* eslint-disable no-restricted-properties */ // @ts-check +import { nodeEnvSchema } from "@eshg/lib-portal/schemas/environment"; import { object, parse, string } from "valibot"; /* @@ -14,6 +15,7 @@ import { object, parse, string } from "valibot"; * Warning: do not expose any secrets here */ const schema = object({ + NODE_ENV: nodeEnvSchema, NEXT_PUBLIC_IMAGE_COMPRESSION_DEFAULT_QUALITY: string(), NEXT_PUBLIC_IMAGE_COMPRESSION_DEFAULT_MAX_SIZE: string(), }); @@ -24,4 +26,5 @@ export const env = parse(schema, { process.env.NEXT_PUBLIC_IMAGE_COMPRESSION_DEFAULT_QUALITY, NEXT_PUBLIC_IMAGE_COMPRESSION_DEFAULT_MAX_SIZE: process.env.NEXT_PUBLIC_IMAGE_COMPRESSION_DEFAULT_MAX_SIZE, + NODE_ENV: process.env.NODE_ENV, }); diff --git a/employee-portal/src/lib/baseModule/components/layout/ChatSettingsSidebar.tsx b/employee-portal/src/lib/baseModule/components/layout/ChatSettingsSidebar.tsx index 6ac5207fc0463d11e4e6d8df5f21e26f61dd8e6a..28a66de0bb521e4676ce3a20bf8c17cc13777c51 100644 --- a/employee-portal/src/lib/baseModule/components/layout/ChatSettingsSidebar.tsx +++ b/employee-portal/src/lib/baseModule/components/layout/ChatSettingsSidebar.tsx @@ -25,14 +25,18 @@ import { DeactivateModalProps, } from "@/lib/businessModules/chat/components/deactivate/DeactivateModal"; import { + clearCachedCredentials, clearMatrixStores, - deleteCachedCredentials, } from "@/lib/businessModules/chat/matrix/tokens"; import { ChatClientContext } from "@/lib/businessModules/chat/shared/ChatClientProvider"; import { useChat } from "@/lib/businessModules/chat/shared/ChatProvider"; import { logger } from "@/lib/businessModules/chat/shared/helpers"; import { useUserSettings } from "@/lib/businessModules/chat/shared/hooks/useUserSettings"; import { termsOfUseText } from "@/lib/businessModules/chat/shared/termsOfUseText"; +import { + setPresenceOffline, + setPresenceOnline, +} from "@/lib/businessModules/chat/shared/utils"; import { DrawerProps } from "@/lib/shared/components/drawer/drawerContext"; import { UseSidebarResult, @@ -49,12 +53,12 @@ export function useChatUserSidebar(): UseSidebarResult { } function ChatSettingsSidebar({ onClose }: DrawerProps) { - const { matrixClient } = useContext(ChatClientContext) ?? {}; + const { matrixClient, isClientPrepared } = + useContext(ChatClientContext) ?? {}; const { tryNavigate } = useNavigation(); const [modalValues, setModalValues] = useState<DeactivateModalProps>(); const [termsOfUseModal, setTermsOfUseModal] = useState(false); const snackbar = useSnackbar(); - const { deactivateAccount } = useUserSettings(); const chatUserId = matrixClient?.getUserId(); @@ -73,6 +77,19 @@ function ChatSettingsSidebar({ onClose }: DrawerProps) { const updateSelfUser = useUpdateSelfUserChatUsername(); const { data: selfUser } = useGetSelfUser(); const { data: userData } = useGetUserProfile(selfUser.userId); + const { deactivateAccount } = useUserSettings(); + + const handlePresenceStatusChange = useCallback(async () => { + togglePresenceStatus(sharePresence); + + if (matrixClient && isClientPrepared) { + if (!sharePresence) { + await setPresenceOffline(matrixClient); + } else { + await setPresenceOnline(matrixClient); + } + } + }, [isClientPrepared, matrixClient, sharePresence, togglePresenceStatus]); const handleStopChat = useCallback(async () => { if (!matrixClient) return; @@ -87,8 +104,8 @@ function ChatSettingsSidebar({ onClose }: DrawerProps) { logger.error(e); } try { - await deleteCachedCredentials(); - void clearMatrixStores(); + clearCachedCredentials(); + await clearMatrixStores(); } catch (error) { logger.error(error); } @@ -134,15 +151,14 @@ function ChatSettingsSidebar({ onClose }: DrawerProps) { session: session, authData: error.data as AuthDict, }); - onClose(); - deactivateAccount(true); const { confirmed } = await modalPromise; if (confirmed) { + deactivateAccount(true); snackbar.notification("Account Deactivated"); } } } - }, [deactivateAccount, matrixClient, onClose, showSSOModal, snackbar]); + }, [deactivateAccount, matrixClient, showSSOModal, snackbar]); return ( <> @@ -161,7 +177,7 @@ function ChatSettingsSidebar({ onClose }: DrawerProps) { startDecorator={ <Switch checked={sharePresence} - onChange={() => togglePresenceStatus(sharePresence)} + onChange={handlePresenceStatusChange} /> } > diff --git a/employee-portal/src/lib/baseModule/components/layout/MainLayout.tsx b/employee-portal/src/lib/baseModule/components/layout/MainLayout.tsx index 50a7abab16f1ec070031342df5b8dc6469a17993..dad25f1da95598227008253503931281f1768f8a 100644 --- a/employee-portal/src/lib/baseModule/components/layout/MainLayout.tsx +++ b/employee-portal/src/lib/baseModule/components/layout/MainLayout.tsx @@ -5,6 +5,7 @@ "use client"; +import { useHeaderHeights } from "@eshg/lib-employee-portal/hooks/useHeaderHeights"; import { Box } from "@mui/joy"; import { ReactNode, useState } from "react"; @@ -14,7 +15,6 @@ import { sideNavigationCollapsedWidth, sideNavigationWidth, } from "@/lib/baseModule/components/layout/sizes"; -import { useHeaderHeights } from "@/lib/baseModule/components/layout/useHeaderHeights"; import { SidebarSlot } from "@/lib/shared/components/drawer/SidebarSlot"; import { useIsOffline } from "@/lib/shared/hooks/useIsOffline"; @@ -76,6 +76,10 @@ export function MainLayout({ children }: { children: ReactNode }) { sm: `calc(100dvh - ${headerHeightDesktop})`, }, }, + minHeight: { + xxs: `calc(100dvh - ${headerHeightMobile})`, + sm: `calc(100dvh - ${headerHeightDesktop})`, + }, }} > {children} diff --git a/employee-portal/src/lib/baseModule/components/layout/header/Header.tsx b/employee-portal/src/lib/baseModule/components/layout/header/Header.tsx index a07cb764b0979db34e96f15792b88bc488925136..9761badf276d64f0596f40f22dcdd1e3afc51fd8 100644 --- a/employee-portal/src/lib/baseModule/components/layout/header/Header.tsx +++ b/employee-portal/src/lib/baseModule/components/layout/header/Header.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { useLayoutConfig } from "@eshg/lib-employee-portal/contexts/layoutConfig"; import { EnvironmentIndicator } from "@eshg/lib-portal/components/EnvironmentIndicator"; import CloseIcon from "@mui/icons-material/Close"; import MenuIcon from "@mui/icons-material/Menu"; @@ -10,16 +11,13 @@ import { Box, Typography } from "@mui/joy"; import { HeaderButtons } from "@/lib/baseModule/components/layout/header/HeaderButtons"; import { HeaderIconButton } from "@/lib/baseModule/components/layout/header/HeaderIconButton"; -import { - appBarHeightDesktop, - appBarHeightMobile, -} from "@/lib/baseModule/components/layout/sizes"; import { useSidenav } from "@/lib/shared/components/drawer/useSidenav"; import { useIsOffline } from "@/lib/shared/hooks/useIsOffline"; export function Header() { const sidenav = useSidenav(); const isOffline = useIsOffline(); + const { appBarHeightMobile, appBarHeightDesktop } = useLayoutConfig(); function toggleSidenav(): void { if (sidenav.isOpen) { diff --git a/employee-portal/src/lib/baseModule/components/layout/header/HeaderButtons.tsx b/employee-portal/src/lib/baseModule/components/layout/header/HeaderButtons.tsx index 46d3ddf5b3c3297e50a024cd5a24305ee7a34222..f5c5b02aba9f74f40f898c1a8967674d3e585921 100644 --- a/employee-portal/src/lib/baseModule/components/layout/header/HeaderButtons.tsx +++ b/employee-portal/src/lib/baseModule/components/layout/header/HeaderButtons.tsx @@ -15,7 +15,7 @@ import { useNotificationsSidebar } from "@/lib/baseModule/components/layout/noti import { useChat } from "@/lib/businessModules/chat/shared/ChatProvider"; import { useGetSelfUserPresence } from "@/lib/businessModules/chat/shared/hooks/useGetSelfUserPresence"; import { - getPresenseLabel, + getPresenceLabel, getStatusColor, } from "@/lib/businessModules/chat/shared/utils"; @@ -76,16 +76,14 @@ export function HeaderButtons() { )} <HeaderIconButton - aria-label={`Benutzer (${getPresenseLabel(userPresence)})`} + aria-label={`Benutzer (${getPresenceLabel(userPresence)})`} sx={{ backgroundColor: "transparent", }} onClick={toggleUserSidebar} > <Badge - invisible={ - !canAccessChat || !sharePresence || userSettings.accountDeactivated - } + invisible={!sharePresence} size="sm" badgeInset="18%" anchorOrigin={{ vertical: "bottom", horizontal: "right" }} diff --git a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/NavigationIconItem.tsx b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/NavigationIconItem.tsx deleted file mode 100644 index d37dbf65313596187d565de60ee3c78142048910..0000000000000000000000000000000000000000 --- a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/NavigationIconItem.tsx +++ /dev/null @@ -1,206 +0,0 @@ -/** - * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { NavigationLink } from "@eshg/lib-portal/components/navigation/NavigationLink"; -import { - Dropdown, - ListItem, - ListItemButton, - MenuButton, - Tooltip, -} from "@mui/joy"; -import { usePathname } from "next/navigation"; -import { - HTMLAttributes, - KeyboardEvent, - MouseEvent, - ReactElement, - ReactNode, - cloneElement, - useRef, - useState, -} from "react"; -import { isDefined } from "remeda"; - -import { ModuleErrorModal } from "@/lib/baseModule/components/layout/sideNavigation/ModuleErrorModal"; -import { NavigationItemError } from "@/lib/baseModule/components/layout/sideNavigation/NavigationItemError"; -import { - navItemSelectedBackgroundColor, - navItemSelectedIconColor, -} from "@/lib/baseModule/components/layout/sideNavigation/constants"; -import { tooltipEnterDelay } from "@/lib/baseModule/components/layout/sizes"; - -import { isItemSelected } from "./isItemSelected"; -import { - SideNavigationItemWithSubItems, - SideNavigationItemWithoutSubItems, -} from "./types"; - -export function NavigationIconItemWithoutSubItems({ - item, - resetActiveIndex, -}: { - item: SideNavigationItemWithoutSubItems; - resetActiveIndex: () => void; -}) { - const pathname = usePathname(); - const selected = isItemSelected(item, pathname); - - return ( - <ListItem> - <Tooltip - title={item.name} - placement="right" - enterDelay={tooltipEnterDelay} - enterNextDelay={tooltipEnterDelay} - > - <ListItemButton - component={NavigationLink} - href={item.href} - selected={selected} - aria-current={selected ? "page" : undefined} - sx={{ - padding: 1, - "&.Mui-selected": { - backgroundColor: navItemSelectedBackgroundColor, - "--Icon-color": navItemSelectedIconColor, - }, - }} - onMouseEnter={resetActiveIndex} - onKeyDown={resetActiveIndex} - onClick={resetActiveIndex} - > - {item.decorator} - </ListItemButton> - </Tooltip> - </ListItem> - ); -} - -interface NavigationIconItemWithSubItemsProps - extends Omit<HTMLAttributes<HTMLButtonElement>, "color"> { - children: ReactNode; - menu: ReactElement; - open: boolean; - onOpen: ( - event?: MouseEvent<HTMLButtonElement> | KeyboardEvent<HTMLButtonElement>, - ) => void; - onLeaveMenu: (callback: () => boolean) => void; - selected: boolean; - item: SideNavigationItemWithSubItems; -} - -const modifiers = [ - { - name: "offset", - options: { - offset: ({ placement }: { placement: string }) => { - if (placement?.includes?.("end")) { - return [8, 20]; - } - return [-8, 20]; - }, - }, - }, -]; - -export function NavigationIconItemWithSubItems({ - children, - menu, - open, - onOpen, - onLeaveMenu, - selected, - item, -}: NavigationIconItemWithSubItemsProps) { - const isOnButton = useRef(false); - - const isItemError = isDefined(item.error); - const [openModuleErrorModal, setopenModuleErrorModal] = useState(false); - - function handleButtonKeyDown(event: KeyboardEvent<HTMLButtonElement>) { - if (event.key === "ArrowDown" || event.key === "ArrowUp") { - onOpen(event); - } - } - - return ( - <Dropdown - open={open} - onOpenChange={(_, isOpen) => { - if (isOpen && !isItemError) { - onOpen?.(); - } - }} - > - <ListItem - sx={{ - height: "38px", - }} - > - {isDefined(item.error) && <NavigationItemError />} - <ModuleErrorModal - open={openModuleErrorModal} - onClose={() => setopenModuleErrorModal(false)} - moduleName={item.name} - /> - <MenuButton - slots={{ root: ListItemButton }} - slotProps={{ - root: { - selected: !isItemError && selected, - sx: { - padding: 1, - "&.Mui-selected": { - backgroundColor: navItemSelectedBackgroundColor, - "--Icon-color": navItemSelectedIconColor, - }, - alignSelf: "unset", - }, - "aria-haspopup": true, - }, - }} - onMouseDown={() => { - if (!isItemError) { - onOpen(); - } - }} - onClick={() => { - if (isItemError) { - setopenModuleErrorModal(true); - } else { - onOpen(); - } - }} - onMouseEnter={() => { - if (!isItemError) { - onOpen(); - isOnButton.current = true; - } - }} - onMouseLeave={() => { - isOnButton.current = false; - }} - onKeyDown={handleButtonKeyDown} - > - {children} - </MenuButton> - </ListItem> - {cloneElement(menu, { - onMouseLeave: () => { - onLeaveMenu(() => isOnButton.current); - }, - modifiers, - slotProps: { - listbox: { - id: `nav-example-menu-${item.name}`, - "aria-label": item.name, - }, - }, - placement: "right-start", - })} - </Dropdown> - ); -} diff --git a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/NavigationListCollapsed.tsx b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/NavigationListCollapsed.tsx deleted file mode 100644 index 28bda5c383d52b4c7ae51340b4e8fc437417563a..0000000000000000000000000000000000000000 --- a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/NavigationListCollapsed.tsx +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { ExpandNavigation } from "@eshg/lib-portal/components/icons/ExpandNavigation"; -import { NavigationLink } from "@eshg/lib-portal/components/navigation/NavigationLink"; -import { - IconButton, - ListItemContent, - Stack, - Tooltip, - Typography, -} from "@mui/joy"; -import Menu from "@mui/joy/Menu"; -import MenuItem from "@mui/joy/MenuItem"; -import { usePathname } from "next/navigation"; -import { Dispatch, SetStateAction, useState } from "react"; - -import { - sideNavigationCollapsedWidth, - tooltipEnterDelay, -} from "@/lib/baseModule/components/layout/sizes"; - -import { - NavigationIconItemWithSubItems, - NavigationIconItemWithoutSubItems, -} from "./NavigationIconItem"; -import { StyledList } from "./StyledList"; -import { listStyling, navItemIconColor, sideNavAriaLabel } from "./constants"; -import { isItemSelected } from "./isItemSelected"; -import { SideNavItemGroups, SideNavigationItem } from "./types"; - -export function NavigationListCollapsed({ - setCollapsed, - itemGroups, -}: { - setCollapsed?: Dispatch<SetStateAction<boolean>>; - itemGroups: SideNavItemGroups; -}) { - const [openMenuItemName, setOpenMenuItemName] = useState<string | null>(null); - - const itemProps = { - onClick: () => setOpenMenuItemName(null), - }; - const pathname = usePathname(); - - function createHandleLeaveMenu(itemName: string) { - return (getIsOnButton: () => boolean) => { - setTimeout(() => { - const isOnButton = getIsOnButton(); - if (!isOnButton) { - setOpenMenuItemName((previousOpenMenuItemName) => { - if (itemName === previousOpenMenuItemName) { - return null; - } - return previousOpenMenuItemName; - }); - } - }, 200); - }; - } - - function getNavItemGroup(itemGroup: SideNavigationItem[]) { - if (itemGroup.length === 0) { - return undefined; - } - - const list = itemGroup.map((item) => { - if ("subItems" in item) { - const isItemMenuOpen = openMenuItemName === item.name; - - return ( - <NavigationIconItemWithSubItems - key={item.name} - item={item} - open={isItemMenuOpen} - onOpen={() => setOpenMenuItemName(item.name)} - onLeaveMenu={createHandleLeaveMenu(item.name)} - selected={ - !isItemMenuOpen && - item.subItems.some((subItem) => isItemSelected(subItem, pathname)) - } - menu={ - <Menu - onClose={() => setOpenMenuItemName(null)} - keepMounted={true} - disablePortal={true} - > - <MenuItem disabled> - <Typography noWrap level="body-sm"> - {item.name} - </Typography> - </MenuItem> - {item.subItems.map((subItem) => ( - <MenuItem - {...itemProps} - key={`${subItem.href}-${subItem.name}`} - component={NavigationLink} - href={subItem.href ?? ""} - selected={isItemSelected(subItem, pathname)} - > - <ListItemContent - sx={{ - borderRadius: (theme) => theme.radius.md, - width: "100%", - }} - > - <Typography noWrap component="span"> - {subItem.name} - </Typography> - </ListItemContent> - </MenuItem> - ))} - </Menu> - } - > - {item.decorator} - </NavigationIconItemWithSubItems> - ); - } - return ( - <NavigationIconItemWithoutSubItems - key={item.name} - item={item} - resetActiveIndex={() => setOpenMenuItemName(null)} - /> - ); - }); - return <StyledList sx={listStyling}>{list}</StyledList>; - } - - return ( - <Stack - component="nav" - aria-label={sideNavAriaLabel} - spacing={3} - sx={{ - width: sideNavigationCollapsedWidth, - backgroundColor: "background.body", - paddingTop: 5, - paddingBottom: 3, - }} - > - <Stack alignItems="center"> - <Tooltip - title="Menü ausklappen" - placement="right" - enterDelay={tooltipEnterDelay} - enterNextDelay={tooltipEnterDelay} - > - <IconButton onClick={() => setCollapsed?.((prevState) => !prevState)}> - <ExpandNavigation sx={{ color: navItemIconColor }} /> - </IconButton> - </Tooltip> - </Stack> - <Stack - flex={1} - alignItems="center" - sx={{ overflowY: "auto", overflowX: "hidden", gap: 3 }} - > - {getNavItemGroup(itemGroups.dashboardItem)} - {getNavItemGroup(itemGroups.businessItems)} - {getNavItemGroup(itemGroups.baseItems)} - </Stack> - </Stack> - ); -} diff --git a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/SideNavigation.tsx b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/SideNavigation.tsx index f99d0defc02d9505990fbccf59a7df514b384f20..d46c078e785ad290cd522b80648ba6a7d2dbefbe 100644 --- a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/SideNavigation.tsx +++ b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/SideNavigation.tsx @@ -3,17 +3,16 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { useHeaderHeights } from "@eshg/lib-employee-portal/hooks/useHeaderHeights"; import { Box, Drawer } from "@mui/joy"; import { Dispatch, SetStateAction } from "react"; -import { NavigationListCollapsed } from "@/lib/baseModule/components/layout/sideNavigation/NavigationListCollapsed"; +import { NavigationListCollapsed } from "@/lib/baseModule/components/layout/sideNavigation/lists/NavigationListCollapsed"; +import { NavigationListExpanded } from "@/lib/baseModule/components/layout/sideNavigation/lists/NavigationListExpanded"; +import { useNavigationItems } from "@/lib/baseModule/components/layout/sideNavigation/useNavigationItems"; import { sideNavigationWidth } from "@/lib/baseModule/components/layout/sizes"; -import { useHeaderHeights } from "@/lib/baseModule/components/layout/useHeaderHeights"; import { useSidenav } from "@/lib/shared/components/drawer/useSidenav"; -import { NavigationListExpanded } from "./NavigationListExpanded"; -import { useNavigationItems } from "./useNavigationItems"; - export function SideNavigation({ collapsed, setCollapsed, @@ -41,13 +40,13 @@ export function SideNavigation({ {!collapsed ? ( <NavigationListExpanded showCollapseButton - setCollapsed={setCollapsed} + onCollapse={() => setCollapsed(true)} itemGroups={itemGroups} isLoading={isLoading} /> ) : ( <NavigationListCollapsed - setCollapsed={setCollapsed} + onExpand={() => setCollapsed(false)} itemGroups={itemGroups} /> )} diff --git a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/constants.ts b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/constants.ts index faf78ea76ecb0eca71330203d10a9633d11df685..43cbba4664b8bdc22fa489c5aa0baef3e6f2442a 100644 --- a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/constants.ts +++ b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/constants.ts @@ -12,8 +12,3 @@ export const navItemSelectedBackgroundColor = theme.palette.primary.softBg; export const navItemIconColor = theme.palette.text.icon; export const navItemSelectedIconColor = theme.palette.primary.softColor; - -export const listStyling = { - // Small extra space that makes room for focus outline (keyboard navigation) - paddingBlock: "0.25rem", -}; diff --git a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/ModuleErrorModal.tsx b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/items/ModuleErrorModal.tsx similarity index 100% rename from employee-portal/src/lib/baseModule/components/layout/sideNavigation/ModuleErrorModal.tsx rename to employee-portal/src/lib/baseModule/components/layout/sideNavigation/items/ModuleErrorModal.tsx diff --git a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/items/NavigationIconItem.tsx b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/items/NavigationIconItem.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bfd377313ef2a6073db283408040635298b76dc7 --- /dev/null +++ b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/items/NavigationIconItem.tsx @@ -0,0 +1,288 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + SideNavigationItem, + SideNavigationItemWithSubItems, + SideNavigationItemWithoutSubItems, +} from "@eshg/lib-employee-portal/types/sideNavigation"; +import { NavigationLink } from "@eshg/lib-portal/components/navigation/NavigationLink"; +import { + Dropdown, + ListItem, + ListItemButton, + ListItemContent, + Menu, + MenuButton, + MenuItem, + Tooltip, + Typography, +} from "@mui/joy"; +import { usePathname } from "next/navigation"; +import { KeyboardEvent, useContext, useRef, useState } from "react"; +import { isDefined } from "remeda"; + +import { + navItemSelectedBackgroundColor, + navItemSelectedIconColor, +} from "@/lib/baseModule/components/layout/sideNavigation/constants"; +import { ModuleErrorModal } from "@/lib/baseModule/components/layout/sideNavigation/items/ModuleErrorModal"; +import { NavigationItemError } from "@/lib/baseModule/components/layout/sideNavigation/items/NavigationItemError"; +import { isItemSelected } from "@/lib/baseModule/components/layout/sideNavigation/items/isItemSelected"; +import { NavigationListCollapsedContext } from "@/lib/baseModule/components/layout/sideNavigation/lists/NavigationListCollapsedContext"; +import { tooltipEnterDelay } from "@/lib/baseModule/components/layout/sizes"; + +function NavigationIconItemWithoutSubItems({ + item, +}: { + item: SideNavigationItemWithoutSubItems; +}) { + const { setOpenMenuItemName } = useContext(NavigationListCollapsedContext); + const pathname = usePathname(); + const selected = isItemSelected(item, pathname); + + function resetActiveIndex() { + setOpenMenuItemName(null); + } + + return ( + <ListItem> + <Tooltip + title={item.name} + placement="right" + enterDelay={tooltipEnterDelay} + enterNextDelay={tooltipEnterDelay} + > + <ListItemButton + component={NavigationLink} + href={item.href} + selected={selected} + aria-current={selected ? "page" : undefined} + sx={{ + padding: 1, + "&.Mui-selected": { + backgroundColor: navItemSelectedBackgroundColor, + "--Icon-color": navItemSelectedIconColor, + }, + }} + onMouseEnter={resetActiveIndex} + onKeyDown={resetActiveIndex} + onClick={resetActiveIndex} + > + {item.decorator} + </ListItemButton> + </Tooltip> + </ListItem> + ); +} + +function ErrorNavigationIconItem({ + item, +}: { + item: SideNavigationItemWithSubItems; +}) { + const { setOpenMenuItemName } = useContext(NavigationListCollapsedContext); + const [errorModalOpen, setErrorModalOpen] = useState(false); + + function resetActiveIndex() { + setOpenMenuItemName(null); + } + + return ( + <> + <ModuleErrorModal + open={errorModalOpen} + onClose={() => setErrorModalOpen(false)} + moduleName={item.name} + /> + <ListItem> + <Tooltip + title={item.name} + placement="right" + enterDelay={tooltipEnterDelay} + enterNextDelay={tooltipEnterDelay} + > + <ListItemButton + sx={{ + padding: 1, + }} + onMouseEnter={resetActiveIndex} + onKeyDown={resetActiveIndex} + onClick={() => { + resetActiveIndex(); + setErrorModalOpen(true); + }} + > + <NavigationItemError /> + {item.decorator} + </ListItemButton> + </Tooltip> + </ListItem> + </> + ); +} + +interface NavigationIconItemWithSubItemsProps { + item: SideNavigationItemWithSubItems; +} + +const modifiers = [ + { + name: "offset", + options: { + offset: ({ placement }: { placement: string }) => { + if (placement?.includes?.("end")) { + return [8, 20]; + } + return [-8, 20]; + }, + }, + }, +]; + +function NavigationIconItemWithSubItems({ + item, +}: NavigationIconItemWithSubItemsProps) { + const { openMenuItemName, setOpenMenuItemName } = useContext( + NavigationListCollapsedContext, + ); + const pathname = usePathname(); + + const isItemMenuOpen = openMenuItemName === item.name; + const selected = + !isItemMenuOpen && + item.subItems.some((subItem) => isItemSelected(subItem, pathname)); + + function createHandleLeaveMenu(itemName: string) { + return (getIsOnButton: () => boolean) => { + setTimeout(() => { + const isOnButton = getIsOnButton(); + if (!isOnButton) { + setOpenMenuItemName((previousOpenMenuItemName) => { + if (itemName === previousOpenMenuItemName) { + return null; + } + return previousOpenMenuItemName; + }); + } + }, 200); + }; + } + + const onLeaveMenu = createHandleLeaveMenu(item.name); + + const isOnButton = useRef(false); + + function onOpen() { + setOpenMenuItemName(item.name); + } + + function handleButtonKeyDown(event: KeyboardEvent<HTMLButtonElement>) { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + onOpen(); + } + } + + return ( + <Dropdown + open={isItemMenuOpen} + onOpenChange={(_, isOpen) => { + if (isOpen) { + onOpen(); + } + }} + > + <ListItem + sx={{ + height: "38px", + }} + > + <MenuButton + aria-label={item.name} + slots={{ root: ListItemButton }} + slotProps={{ + root: { + selected: selected, + sx: { + padding: 1, + "&.Mui-selected": { + backgroundColor: navItemSelectedBackgroundColor, + "--Icon-color": navItemSelectedIconColor, + }, + alignSelf: "unset", + }, + "aria-haspopup": true, + }, + }} + onMouseDown={() => { + onOpen(); + }} + onClick={() => { + onOpen(); + }} + onMouseEnter={() => { + onOpen(); + isOnButton.current = true; + }} + onMouseLeave={() => { + isOnButton.current = false; + }} + onKeyDown={handleButtonKeyDown} + > + {item.decorator} + </MenuButton> + </ListItem> + <Menu + onClose={() => setOpenMenuItemName(null)} + keepMounted={true} + disablePortal={true} + onMouseLeave={() => { + onLeaveMenu(() => isOnButton.current); + }} + modifiers={modifiers} + placement="right-start" + > + <MenuItem disabled> + <Typography noWrap level="body-sm"> + {item.name} + </Typography> + </MenuItem> + {item.subItems.map((subItem) => ( + <MenuItem + onClick={() => setOpenMenuItemName(null)} + key={`${subItem.href}-${subItem.name}`} + component={NavigationLink} + href={subItem.href ?? ""} + selected={isItemSelected(subItem, pathname)} + aria-current={ + isItemSelected(subItem, pathname) ? "page" : undefined + } + > + <ListItemContent + sx={{ + borderRadius: (theme) => theme.radius.md, + width: "100%", + }} + > + <Typography noWrap component="span"> + {subItem.name} + </Typography> + </ListItemContent> + </MenuItem> + ))} + </Menu> + </Dropdown> + ); +} + +export function NavigationIconItem({ item }: { item: SideNavigationItem }) { + if ("subItems" in item) { + if (isDefined(item.error)) { + return <ErrorNavigationIconItem item={item} />; + } + return <NavigationIconItemWithSubItems item={item} />; + } + return <NavigationIconItemWithoutSubItems item={item} />; +} diff --git a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/NavigationItem.tsx b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/items/NavigationItem.tsx similarity index 66% rename from employee-portal/src/lib/baseModule/components/layout/sideNavigation/NavigationItem.tsx rename to employee-portal/src/lib/baseModule/components/layout/sideNavigation/items/NavigationItem.tsx index b57e0ba395d6665491681ef6147615b554d6b5a7..57f148d2413df35f9913b96ef710da08cc2d019e 100644 --- a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/NavigationItem.tsx +++ b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/items/NavigationItem.tsx @@ -3,6 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { + SideNavigationItem, + SideNavigationItemWithSubItems, + SideNavigationItemWithoutSubItems, +} from "@eshg/lib-employee-portal/types/sideNavigation"; import { NavigationLink } from "@eshg/lib-portal/components/navigation/NavigationLink"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { @@ -14,24 +19,19 @@ import { ListItemDecorator, Typography, } from "@mui/joy"; +import { SxProps } from "@mui/joy/styles/types"; import { usePathname } from "next/navigation"; -import { useEffect, useId, useState } from "react"; +import { ReactNode, useEffect, useId, useState } from "react"; import { isDefined } from "remeda"; -import { ModuleErrorModal } from "@/lib/baseModule/components/layout/sideNavigation/ModuleErrorModal"; -import { NavigationItemError } from "@/lib/baseModule/components/layout/sideNavigation/NavigationItemError"; import { navItemIconColor, navItemSelectedBackgroundColor, navItemSelectedIconColor, } from "@/lib/baseModule/components/layout/sideNavigation/constants"; - -import { isItemSelected } from "./isItemSelected"; -import { - SideNavigationItem, - SideNavigationItemWithSubItems, - SideNavigationItemWithoutSubItems, -} from "./types"; +import { ModuleErrorModal } from "@/lib/baseModule/components/layout/sideNavigation/items/ModuleErrorModal"; +import { NavigationItemError } from "@/lib/baseModule/components/layout/sideNavigation/items/NavigationItemError"; +import { isItemSelected } from "@/lib/baseModule/components/layout/sideNavigation/items/isItemSelected"; function textColor(selected: boolean) { return selected ? "primary.softColor" : "text.primary"; @@ -51,6 +51,50 @@ const spacings = { navItemPadding: "0.375rem", }; +function listItemButtonStyle(expanded: boolean): SxProps { + return { + alignItems: "flex-start", + padding: spacings.navItemPadding, + "&.Mui-selected": { + backgroundColor: navItemSelectedBackgroundColor, + }, + marginBottom: expanded ? "0.5rem" : 0, + }; +} + +function Decorator(props: { selected: boolean; children: ReactNode }) { + return ( + <ListItemDecorator + sx={{ + marginTop: spacings.iconTopSpacing, + "--ListItemDecorator-size": "2rem", + "--Icon-color": iconColor(props.selected), + }} + > + {props.children} + </ListItemDecorator> + ); +} + +function ItemLabel(props: { selected: boolean; children: ReactNode }) { + return ( + <ListItemContent> + <Typography + sx={{ + marginTop: spacings.textTopSpacing, + overflowWrap: "break-word", + hyphens: "auto", + }} + component="span" + level={textStyle(props.selected)} + textColor={textColor(props.selected)} + > + {props.children} + </Typography> + </ListItemContent> + ); +} + function NavigationItemWithoutSubItems({ item, }: { @@ -67,39 +111,44 @@ function NavigationItemWithoutSubItems({ href={item.href} selected={selected} aria-current={selected ? "page" : undefined} - sx={{ - padding: spacings.navItemPadding, - alignItems: "flex-start", - "&.Mui-selected": { - backgroundColor: navItemSelectedBackgroundColor, - }, - }} + sx={listItemButtonStyle(false)} > - <ListItemDecorator - sx={{ - marginTop: spacings.iconTopSpacing, - "--Icon-color": iconColor(selected), - "--ListItemDecorator-size": "2rem", - }} - > - {item.decorator} - </ListItemDecorator> - <ListItemContent> - <Typography - sx={{ marginTop: spacings.textTopSpacing }} - component="span" - level={textStyle(selected)} - textColor={textColor(selected)} - > - {item.name} - </Typography> - </ListItemContent> + <Decorator selected={selected}>{item.decorator}</Decorator> + <ItemLabel selected={selected}>{item.name}</ItemLabel> {item.chip} </ListItemButton> </ListItem> ); } +function ErrorNavigationItem({ + item, +}: { + item: SideNavigationItemWithSubItems; +}) { + const [errorModalOpen, setErrorModalOpen] = useState(false); + + return ( + <> + <ModuleErrorModal + open={errorModalOpen} + onClose={() => setErrorModalOpen(false)} + moduleName={item.name} + /> + <ListItem> + <ListItemButton + sx={listItemButtonStyle(false)} + onClick={() => setErrorModalOpen(true)} + > + <NavigationItemError /> + <Decorator selected={false}>{item.decorator}</Decorator> + <ItemLabel selected={false}>{item.name}</ItemLabel> + </ListItemButton> + </ListItem> + </> + ); +} + function NavigationItemWithSubItems({ item, }: { @@ -109,14 +158,10 @@ function NavigationItemWithSubItems({ const expandableContentId = useId(); const pathname = usePathname(); - const [openModuleErrorModal, setopenModuleErrorModal] = useState(false); - const isItemError = isDefined(item.error); - const selected = - !isItemError && - item.subItems.some((subItem) => { - return isItemSelected(subItem, pathname); - }); + const selected = item.subItems.some((subItem) => { + return isItemSelected(subItem, pathname); + }); const [expanded, setExpanded] = useState(selected); useEffect(() => { @@ -127,55 +172,17 @@ function NavigationItemWithSubItems({ return ( <ListItem nested> - {isDefined(item.error) && <NavigationItemError />} - <ModuleErrorModal - open={openModuleErrorModal} - onClose={() => setopenModuleErrorModal(false)} - moduleName={item.name} - /> <ListItemButton role="button" - onClick={ - isItemError - ? () => setopenModuleErrorModal(true) - : () => setExpanded((prevState) => !prevState) - } + onClick={() => setExpanded((prevState) => !prevState)} selected={selected && !expanded} - sx={{ - alignItems: "flex-start", - marginBottom: expanded ? "0.5rem" : 0, - padding: spacings.navItemPadding, - }} + sx={listItemButtonStyle(expanded)} id={buttonId} aria-expanded={expanded} aria-controls={expandableContentId} > - <ListItemDecorator - sx={{ - marginTop: spacings.iconTopSpacing, - "--ListItemDecorator-size": "2rem", - "--Icon-color": iconColor(selected), - }} - > - {item.decorator} - </ListItemDecorator> - <ListItemContent - sx={{ - marginTop: spacings.textTopSpacing, - marginRight: 1, - textOverflow: "ellipsis", - overflow: "hidden", - }} - > - <Typography - sx={{ hyphens: "auto" }} - component="span" - level={textStyle(selected)} - textColor={textColor(selected)} - > - {item.name} - </Typography> - </ListItemContent> + <Decorator selected={selected}>{item.decorator}</Decorator> + <ItemLabel selected={selected}>{item.name}</ItemLabel> <KeyboardArrowDownIcon sx={{ marginTop: spacings.iconTopSpacing, @@ -262,6 +269,9 @@ function NavigationItemWithSubItems({ export function NavigationItem({ item }: { item: SideNavigationItem }) { if ("subItems" in item) { + if (isDefined(item.error)) { + return <ErrorNavigationItem item={item} />; + } return <NavigationItemWithSubItems item={item} />; } diff --git a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/NavigationItemError.tsx b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/items/NavigationItemError.tsx similarity index 100% rename from employee-portal/src/lib/baseModule/components/layout/sideNavigation/NavigationItemError.tsx rename to employee-portal/src/lib/baseModule/components/layout/sideNavigation/items/NavigationItemError.tsx diff --git a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/isItemSelected.ts b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/items/isItemSelected.ts similarity index 86% rename from employee-portal/src/lib/baseModule/components/layout/sideNavigation/isItemSelected.ts rename to employee-portal/src/lib/baseModule/components/layout/sideNavigation/items/isItemSelected.ts index cff8e72026d05bfde527f56e09dad6fea69c9330..dbd3edcae47aecacfa204cbd69416a8b8994b04d 100644 --- a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/isItemSelected.ts +++ b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/items/isItemSelected.ts @@ -6,7 +6,7 @@ import { SideNavigationItemWithoutSubItems, SideNavigationSubItem, -} from "./types"; +} from "@eshg/lib-employee-portal/types/sideNavigation"; export function isItemSelected( item: SideNavigationItemWithoutSubItems | SideNavigationSubItem, diff --git a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/lists/NavigationListCollapsed.tsx b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/lists/NavigationListCollapsed.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0bf0a33aaf7825dbe8ae52eed09365bf7a7d5495 --- /dev/null +++ b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/lists/NavigationListCollapsed.tsx @@ -0,0 +1,83 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { SideNavigationItem } from "@eshg/lib-employee-portal/types/sideNavigation"; +import { ExpandNavigation } from "@eshg/lib-portal/components/icons/ExpandNavigation"; +import { IconButton, Stack, Tooltip } from "@mui/joy"; +import { useState } from "react"; + +import { + navItemIconColor, + sideNavAriaLabel, +} from "@/lib/baseModule/components/layout/sideNavigation/constants"; +import { NavigationIconItem } from "@/lib/baseModule/components/layout/sideNavigation/items/NavigationIconItem"; +import { NavigationListCollapsedContext } from "@/lib/baseModule/components/layout/sideNavigation/lists/NavigationListCollapsedContext"; +import { StyledList } from "@/lib/baseModule/components/layout/sideNavigation/lists/StyledList"; +import { SideNavItemGroups } from "@/lib/baseModule/components/layout/sideNavigation/types"; +import { + sideNavigationCollapsedWidth, + tooltipEnterDelay, +} from "@/lib/baseModule/components/layout/sizes"; + +function NavigationItemGroup(props: { itemGroup: SideNavigationItem[] }) { + if (props.itemGroup.length === 0) { + return undefined; + } + + const list = props.itemGroup.map((item) => { + return <NavigationIconItem key={item.name} item={item} />; + }); + return <StyledList>{list}</StyledList>; +} + +export function NavigationListCollapsed({ + onExpand, + itemGroups, +}: { + onExpand: () => void; + itemGroups: SideNavItemGroups; +}) { + const [openMenuItemName, setOpenMenuItemName] = useState<string | null>(null); + + return ( + <Stack + component="nav" + aria-label={sideNavAriaLabel} + spacing={3} + sx={{ + width: sideNavigationCollapsedWidth, + backgroundColor: "background.body", + paddingTop: 5, + paddingBottom: 3, + }} + > + <Stack alignItems="center"> + <Tooltip + title="Menü ausklappen" + placement="right" + enterDelay={tooltipEnterDelay} + enterNextDelay={tooltipEnterDelay} + > + <IconButton onClick={onExpand}> + <ExpandNavigation sx={{ color: navItemIconColor }} /> + </IconButton> + </Tooltip> + </Stack> + <Stack + flex={1} + alignItems="center" + sx={{ overflowY: "auto", overflowX: "hidden", gap: 3 }} + > + <NavigationListCollapsedContext.Provider + value={{ openMenuItemName, setOpenMenuItemName }} + > + <NavigationItemGroup itemGroup={itemGroups.dashboardItem} /> + <NavigationItemGroup itemGroup={itemGroups.businessItems} /> + <NavigationItemGroup itemGroup={itemGroups.baseItems} /> + </NavigationListCollapsedContext.Provider> + </Stack> + </Stack> + ); +} diff --git a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/lists/NavigationListCollapsedContext.ts b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/lists/NavigationListCollapsedContext.ts new file mode 100644 index 0000000000000000000000000000000000000000..8646fd23392b1bae4b72db693a44d9b1fa22a953 --- /dev/null +++ b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/lists/NavigationListCollapsedContext.ts @@ -0,0 +1,14 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Dispatch, SetStateAction, createContext } from "react"; + +interface NavigationListCollapsedContextValue { + openMenuItemName: string | null; + setOpenMenuItemName: Dispatch<SetStateAction<string | null>>; +} + +export const NavigationListCollapsedContext = + createContext<NavigationListCollapsedContextValue>(null!); diff --git a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/NavigationListExpanded.tsx b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/lists/NavigationListExpanded.tsx similarity index 61% rename from employee-portal/src/lib/baseModule/components/layout/sideNavigation/NavigationListExpanded.tsx rename to employee-portal/src/lib/baseModule/components/layout/sideNavigation/lists/NavigationListExpanded.tsx index 6d9271dc47731f26f8f7919b977199e48e2048e6..9f794330d8d96a381f33087e417be326baa18fa6 100644 --- a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/NavigationListExpanded.tsx +++ b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/lists/NavigationListExpanded.tsx @@ -3,38 +3,41 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { SideNavigationItem } from "@eshg/lib-employee-portal/types/sideNavigation"; import { LoadingOverlay } from "@eshg/lib-portal/components/LoadingOverlay"; import { ExpandNavigation } from "@eshg/lib-portal/components/icons/ExpandNavigation"; import { Button, Stack, Typography } from "@mui/joy"; -import { Dispatch, SetStateAction } from "react"; -import { NavigationItem } from "@/lib/baseModule/components/layout/sideNavigation/NavigationItem"; +import { + navItemIconColor, + sideNavAriaLabel, +} from "@/lib/baseModule/components/layout/sideNavigation/constants"; +import { NavigationItem } from "@/lib/baseModule/components/layout/sideNavigation/items/NavigationItem"; +import { StyledList } from "@/lib/baseModule/components/layout/sideNavigation/lists/StyledList"; +import { SideNavItemGroups } from "@/lib/baseModule/components/layout/sideNavigation/types"; import { sideNavigationWidth } from "@/lib/baseModule/components/layout/sizes"; -import { StyledList } from "./StyledList"; -import { listStyling, navItemIconColor, sideNavAriaLabel } from "./constants"; -import { SideNavItemGroups, SideNavigationItem } from "./types"; +function NavigationItemGroup(props: { itemGroup: SideNavigationItem[] }) { + if (props.itemGroup.length === 0) { + return undefined; + } + const list = props.itemGroup.map((item) => { + return <NavigationItem key={item.name} item={item} />; + }); + return <StyledList>{list}</StyledList>; +} export function NavigationListExpanded({ - setCollapsed, + onCollapse, showCollapseButton, itemGroups, isLoading, }: { - setCollapsed?: Dispatch<SetStateAction<boolean>>; + onCollapse?: () => void; showCollapseButton: boolean; itemGroups: SideNavItemGroups; isLoading: boolean; }) { - function getNavItemGroup(itemGroup: SideNavigationItem[]) { - if (itemGroup.length > 0) { - const list = itemGroup.map((item) => { - return <NavigationItem key={item.name} item={item} />; - }); - return <StyledList sx={listStyling}>{list}</StyledList>; - } else return undefined; - } - return ( <Stack component="nav" @@ -50,7 +53,7 @@ export function NavigationListExpanded({ {showCollapseButton && ( <Button variant="plain" - onClick={() => setCollapsed?.((prevState) => !prevState)} + onClick={onCollapse} sx={{ whiteSpace: "nowrap", justifyContent: "space-between", @@ -66,9 +69,9 @@ export function NavigationListExpanded({ </Button> )} <Stack flex={1} sx={{ overflowY: "auto", paddingInline: 2, gap: 3 }}> - {getNavItemGroup(itemGroups.dashboardItem)} - {getNavItemGroup(itemGroups.businessItems)} - {getNavItemGroup(itemGroups.baseItems)} + <NavigationItemGroup itemGroup={itemGroups.dashboardItem} /> + <NavigationItemGroup itemGroup={itemGroups.businessItems} /> + <NavigationItemGroup itemGroup={itemGroups.baseItems} /> {isLoading && <LoadingOverlay />} </Stack> </Stack> diff --git a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/StyledList.ts b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/lists/StyledList.ts similarity index 73% rename from employee-portal/src/lib/baseModule/components/layout/sideNavigation/StyledList.ts rename to employee-portal/src/lib/baseModule/components/layout/sideNavigation/lists/StyledList.ts index 16adf277d7d50ea8fe2f880d83e13263e3dec5b3..60e302433578bc9785c6ddbedd72421b03b7fcdc 100644 --- a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/StyledList.ts +++ b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/lists/StyledList.ts @@ -11,4 +11,6 @@ export const StyledList = styled(List)(({ theme }) => ({ gap: theme.spacing(1), "--ListItem-radius": theme.radius.md, position: "static", + // Small extra space that makes room for focus outline (keyboard navigation) + paddingBlock: "0.25rem", })); diff --git a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/types.ts b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/types.ts index 17df9039878215d3ae4980b25e6001153839598b..c6d4d3b56ca9669e21da6c0267c172f489889ad4 100644 --- a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/types.ts +++ b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/types.ts @@ -3,49 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { AccessCheck } from "@eshg/lib-employee-portal/helpers/accessControl"; -import { ReactNode } from "react"; - -export interface SideNavigationItemWithoutSubItems { - name: string; - href: string; - decorator: ReactNode; - accessCheck: AccessCheck; - chip?: ReactNode; -} - -export interface SideNavigationItemWithSubItems { - name: string; - decorator: ReactNode; - subItems: SideNavigationSubItem[]; - /** - * Errors can occur when resolving the navigation items. - * This can happen, for example, when querying feature toggles of a module that's currently not available. - * In this case, the main navigation item is deactivated and an error icon with tooltip is displayed. - */ - error?: string; -} - -export interface SideNavigationSubItem { - name: string; - href: string; - accessCheck: AccessCheck; -} - -export type SideNavigationItem = - | SideNavigationItemWithoutSubItems - | SideNavigationItemWithSubItems; - -export interface UseSideNavigationItemsResult { - isLoading: boolean; - items: SideNavigationItem[]; -} +import { SideNavigationItem } from "@eshg/lib-employee-portal/types/sideNavigation"; export interface SideNavItemGroups { dashboardItem: SideNavigationItem[]; businessItems: SideNavigationItem[]; baseItems: SideNavigationItem[]; } + export interface UseSideNavigationItemGroupsResult { isLoading: boolean; itemGroups: SideNavItemGroups; diff --git a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/useNavigationItems.ts b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/useNavigationItems.ts index 2f457d12dd24e8b9557fca644d955aaf80bc635e..825b05fbf8e79d859f84398dde064c510d064802 100644 --- a/employee-portal/src/lib/baseModule/components/layout/sideNavigation/useNavigationItems.ts +++ b/employee-portal/src/lib/baseModule/components/layout/sideNavigation/useNavigationItems.ts @@ -4,11 +4,12 @@ */ import { AccessCheck } from "@eshg/lib-employee-portal/helpers/accessControl"; +import { SideNavigationItem } from "@eshg/lib-employee-portal/types/sideNavigation"; import { useResolveSideNavigationItems } from "@/lib/baseModule/moduleRegister/sideNavigationItemsResolver"; import { useAccessControl } from "@/lib/shared/hooks/useAccessControl"; -import { SideNavigationItem, UseSideNavigationItemGroupsResult } from "./types"; +import { UseSideNavigationItemGroupsResult } from "./types"; export function filterNavigationItemsWithAccess( items: SideNavigationItem[], diff --git a/employee-portal/src/lib/baseModule/components/layout/sizes.ts b/employee-portal/src/lib/baseModule/components/layout/sizes.ts index 96bf1977d39afbda9278a4b0252ff85cd49e7b4e..fb8edb5415d7e7eeec043c3396fb8a49df030002 100644 --- a/employee-portal/src/lib/baseModule/components/layout/sizes.ts +++ b/employee-portal/src/lib/baseModule/components/layout/sizes.ts @@ -3,11 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export const appBarHeightMobile = "3.5rem"; // 56px -export const appBarHeightDesktop = "4.5rem"; // 72px - -export const simpleToolbarHeight = "3.625rem"; //58px - export const sideNavigationWidth = "15rem"; // 240px export const sideNavigationCollapsedWidth = "4rem"; // 64px diff --git a/employee-portal/src/lib/baseModule/components/users/userSidebar/UserSidebarHeader.tsx b/employee-portal/src/lib/baseModule/components/users/userSidebar/UserSidebarHeader.tsx index bd2f52bcca71255762eff5eef534ccc0a8b6d55d..f125ad721c6dc1b81c9fbfefc29c6d5cb61630a1 100644 --- a/employee-portal/src/lib/baseModule/components/users/userSidebar/UserSidebarHeader.tsx +++ b/employee-portal/src/lib/baseModule/components/users/userSidebar/UserSidebarHeader.tsx @@ -9,7 +9,7 @@ import { Badge, DialogTitle, Stack, Typography } from "@mui/joy"; import { UserAvatar } from "@/lib/baseModule/components/users/UserAvatar"; import { useGetSelfUserPresence } from "@/lib/businessModules/chat/shared/hooks/useGetSelfUserPresence"; import { - getPresenseLabel, + getPresenceLabel, getStatusColor, } from "@/lib/businessModules/chat/shared/utils"; import { sidebarPadding } from "@/lib/shared/components/sidebar/Sidebar"; @@ -32,7 +32,7 @@ export function UserSidebarHeader({ selfUser }: { selfUser: ApiUser }) { invisible={!sharePresence} variant="solid" size="md" - aria-label={`Benutzer (${getPresenseLabel(userPresence)})`} + aria-label={`Benutzer (${getPresenceLabel(userPresence)})`} sx={{ "& .MuiBadge-badge": { backgroundColor: getStatusColor(userPresence), diff --git a/employee-portal/src/lib/baseModule/moduleRegister/sideNavigationItemsResolver.tsx b/employee-portal/src/lib/baseModule/moduleRegister/sideNavigationItemsResolver.tsx index f8637493ecbf657d22d68f25bc6cba5079dde2a4..caa679f183f13733c782b636064ced4d9af4e2a8 100644 --- a/employee-portal/src/lib/baseModule/moduleRegister/sideNavigationItemsResolver.tsx +++ b/employee-portal/src/lib/baseModule/moduleRegister/sideNavigationItemsResolver.tsx @@ -5,14 +5,14 @@ import { ApiBusinessModule } from "@eshg/base-api"; import { useSideNavigationItems as useDentalSideNavigationItems } from "@eshg/dental/shared/useSideNavigationItems"; -import { mapToObj } from "remeda"; - -import { useServerConfig } from "@/lib/baseModule/api/queries/config"; import { - SideNavItemGroups, SideNavigationItem, UseSideNavigationItemsResult, -} from "@/lib/baseModule/components/layout/sideNavigation/types"; +} from "@eshg/lib-employee-portal/types/sideNavigation"; +import { mapToObj } from "remeda"; + +import { useServerConfig } from "@/lib/baseModule/api/queries/config"; +import { SideNavItemGroups } from "@/lib/baseModule/components/layout/sideNavigation/types"; import { useSideNavigationItems as useBaseSideNavigationItems, useDashboardItem, diff --git a/employee-portal/src/lib/baseModule/sideNavigationItems.tsx b/employee-portal/src/lib/baseModule/sideNavigationItems.tsx index fd374463ed8d784579b57594f940905e470d1aac..9ae45baa0b4caedbf122057a7b7dacc718e2faa9 100644 --- a/employee-portal/src/lib/baseModule/sideNavigationItems.tsx +++ b/employee-portal/src/lib/baseModule/sideNavigationItems.tsx @@ -8,6 +8,10 @@ import { hasUserRole, noCheck, } from "@eshg/lib-employee-portal/helpers/accessControl"; +import { + SideNavigationItem, + UseSideNavigationItemsResult, +} from "@eshg/lib-employee-portal/types/sideNavigation"; import { CalendarTodayOutlined, ContactsOutlined, @@ -24,10 +28,6 @@ import { } from "@mui/icons-material"; import { useIsNewFeatureEnabled } from "@/lib/baseModule/api/queries/feature"; -import { - SideNavigationItem, - UseSideNavigationItemsResult, -} from "@/lib/baseModule/components/layout/sideNavigation/types"; import { routes } from "./shared/routes"; diff --git a/employee-portal/src/lib/baseModule/theme/customBreakpoints.ts b/employee-portal/src/lib/baseModule/theme/customBreakpoints.ts index 61931ec5c1f03b2ed114265c406381e448259b82..5d523a9191fd170467801fce70f0f2614685b2df 100644 --- a/employee-portal/src/lib/baseModule/theme/customBreakpoints.ts +++ b/employee-portal/src/lib/baseModule/theme/customBreakpoints.ts @@ -5,13 +5,6 @@ import { CssVarsThemeOptions } from "@mui/joy/styles"; -declare module "@mui/joy/styles" { - interface BreakpointOverrides { - xxs: true; - xxl: true; - } -} - export const customBreakpoints = { values: { xxs: 0, diff --git a/employee-portal/src/lib/baseModule/theme/theme.ts b/employee-portal/src/lib/baseModule/theme/theme.ts index ba1d5d77c42f79151c13c63f1c85df9aa782e7a9..2685b43394b46774528465cad2aa85a6ecb1402c 100644 --- a/employee-portal/src/lib/baseModule/theme/theme.ts +++ b/employee-portal/src/lib/baseModule/theme/theme.ts @@ -13,40 +13,12 @@ import "@fontsource/poppins/600.css"; import "@fontsource/poppins/700.css"; import "@fontsource/source-code-pro/400.css"; import "@fontsource/source-code-pro/600.css"; -import { FontSize, Theme, extendTheme } from "@mui/joy/styles"; +import { Theme, extendTheme } from "@mui/joy/styles"; import { SxProps } from "@mui/joy/styles/types"; import { isNullish } from "remeda"; import { customBreakpoints } from "./customBreakpoints"; -declare module "@mui/joy/styles" { - interface BreakpointOverrides { - xxs: true; - xxl: true; - } -} - -declare module "@mui/joy/styles/types/zIndex" { - interface ZIndexOverrides { - toolbar: true; - sidebar: true; - sideNavigation: true; - header: true; - } -} - -declare module "@mui/joy/ToggleButtonGroup" { - interface ToggleButtonGroupPropsVariantOverrides { - tabs: true; - } -} - -type FontSizeOverrides = { [_k in keyof FontSize]: true }; -declare module "@mui/joy/SvgIcon" { - // eslint-disable-next-line @typescript-eslint/no-empty-object-type - interface SvgIconPropsSizeOverrides extends FontSizeOverrides {} -} - const noBackdrop = { backdropFilter: "none", }; @@ -239,9 +211,6 @@ export const theme = extendTheme({ color: theme.palette[ownerState.color].plainColor, }), lineHeight: 1.5, - [theme.breakpoints.up("sm")]: { - fontSize: "1.25rem", - }, }), }, }, diff --git a/employee-portal/src/lib/businessModules/chat/api/clients.ts b/employee-portal/src/lib/businessModules/chat/api/clients.ts index ec71169b9a9690d29c03b03bd6e67a00a03b48a0..b1f071d263a49486e992dd785055fd248567cecb 100644 --- a/employee-portal/src/lib/businessModules/chat/api/clients.ts +++ b/employee-portal/src/lib/businessModules/chat/api/clients.ts @@ -6,6 +6,7 @@ import { ChatFeatureTogglesApi, Configuration, + UserAccountApi, UserSettingsApi, } from "@eshg/chat-management-api"; import { useApiConfiguration } from "@eshg/lib-portal/api/ApiProvider"; @@ -27,3 +28,8 @@ export function useFeatureTogglesApi() { const configuration = useConfiguration(); return new ChatFeatureTogglesApi(configuration); } + +export function useUserAccountApi() { + const configuration = useConfiguration(); + return new UserAccountApi(configuration); +} diff --git a/employee-portal/src/lib/businessModules/chat/api/mutations/userAccountApi.ts b/employee-portal/src/lib/businessModules/chat/api/mutations/userAccountApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..bbaca7be85e6b3240bbe4523c42a48ecfc47fe8a --- /dev/null +++ b/employee-portal/src/lib/businessModules/chat/api/mutations/userAccountApi.ts @@ -0,0 +1,23 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ApiBindKeycloakIdRequest } from "@eshg/chat-management-api"; +import { useSnackbar } from "@eshg/lib-portal/components/snackbar/SnackbarProvider"; +import { useMutation } from "@tanstack/react-query"; + +import { useUserAccountApi } from "@/lib/businessModules/chat/api/clients"; + +export function useBindKeycloakId() { + const userAccountApi = useUserAccountApi(); + const snackbar = useSnackbar(); + + return useMutation({ + mutationFn: (request: ApiBindKeycloakIdRequest) => + userAccountApi.bindKeycloakId(request), + onError: () => { + snackbar.error("Etwas ist schief gelaufen"); + }, + }); +} diff --git a/employee-portal/src/lib/businessModules/chat/api/queries/apiQueryKeys.ts b/employee-portal/src/lib/businessModules/chat/api/queries/apiQueryKeys.ts index 1b48d407a09ef6b192307447050fa3c2df6410ea..962c79da076477275adc96a7c4340efa2ddfefb9 100644 --- a/employee-portal/src/lib/businessModules/chat/api/queries/apiQueryKeys.ts +++ b/employee-portal/src/lib/businessModules/chat/api/queries/apiQueryKeys.ts @@ -18,3 +18,7 @@ export const chatFeatureTogglesApiQueryKey = queryKeyFactory( export const departmentApiQueryKey = queryKeyFactory( apiQueryKey(["departmentApi"]), ); + +export const userAccountApiQueryKey = queryKeyFactory( + apiQueryKey(["userAccountApi"]), +); diff --git a/employee-portal/src/lib/businessModules/chat/components/Chat.tsx b/employee-portal/src/lib/businessModules/chat/components/Chat.tsx index 282ef4dc468ba582eeffb4e23d58ee35153a932e..1e43777f660b81bcaab3ec41411c1f8bdeef3998 100644 --- a/employee-portal/src/lib/businessModules/chat/components/Chat.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/Chat.tsx @@ -33,7 +33,8 @@ export function Chat() { const userIdForChatStart = searchParams.get("userId"); const lastUserIdForChatStart = useRef(""); const theme = useTheme(); - const { clientState, matrixClient } = useChatClientContext(); + const { clientState, matrixClient, isClientPrepared } = + useChatClientContext(); const { infoPanelState } = useInfoPanelContext(); const { createNewChat } = useCreateNewChat(); const [chatPanelView, setChatPanelView] = useState<ChatPanelView>( @@ -61,13 +62,13 @@ export function Chat() { if ( userIdForChatStart && - clientState === ClientState.Prepared && + isClientPrepared && lastUserIdForChatStart.current !== userIdForChatStart ) { void createDMChat(userIdForChatStart); lastUserIdForChatStart.current = userIdForChatStart; } - }, [clientState, userIdForChatStart, matrixClient, createNewChat]); + }, [userIdForChatStart, matrixClient, createNewChat, isClientPrepared]); if ( clientState === ClientState.CreateBackupKey || @@ -76,7 +77,7 @@ export function Chat() { return <BackupSetupView />; } - if (clientState !== ClientState.Prepared) { + if (!isClientPrepared) { return <LoadingIndicator text="Seite wird geladen…" fullHeight />; } diff --git a/employee-portal/src/lib/businessModules/chat/components/ChatConsentModal.tsx b/employee-portal/src/lib/businessModules/chat/components/ChatConsentModal.tsx index c9d91f157c08b20ec3482a48e227fd71ea8e90b4..d37b35fcd9332073e94249c0212645db5a5c4787 100644 --- a/employee-portal/src/lib/businessModules/chat/components/ChatConsentModal.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/ChatConsentModal.tsx @@ -24,8 +24,8 @@ type ChatConsentModalProps = Omit< export function ChatConsentModal(props: ChatConsentModalProps) { const { updateChatUserConsents } = useUserSettings(); - async function handleAcceptClick() { - await clearCachedCredentials(); + function handleAcceptClick() { + clearCachedCredentials(); updateChatUserConsents({ isChatConsentAsked: true, isChatUsageEnabled: true, diff --git a/employee-portal/src/lib/businessModules/chat/components/ChatErrorBoundary.tsx b/employee-portal/src/lib/businessModules/chat/components/ChatErrorBoundary.tsx index 848c7dff23902618dd41b4f1cc5da6aec3f54a58..4a059b50bd893a811ae9b7af5fef7c8e5c3b0456 100644 --- a/employee-portal/src/lib/businessModules/chat/components/ChatErrorBoundary.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/ChatErrorBoundary.tsx @@ -4,12 +4,15 @@ */ import { ErrorAlert } from "@eshg/lib-portal/errorHandling/ErrorAlert"; +import { useRouter } from "next/navigation"; import { PropsWithChildren } from "react"; import { useChatClientContext } from "@/lib/businessModules/chat/shared/ChatClientProvider"; import { ClientState } from "@/lib/businessModules/chat/shared/enums"; +import { logger } from "@/lib/businessModules/chat/shared/helpers"; export function ChatErrorBoundary({ children }: PropsWithChildren) { + const { refresh } = useRouter(); const { clientState, setClientState } = useChatClientContext(); if (clientState === ClientState.Error) { @@ -17,7 +20,12 @@ export function ChatErrorBoundary({ children }: PropsWithChildren) { <ErrorAlert error={"Chat Error"} onReset={() => { - setClientState(ClientState.Restart); + try { + refresh(); + setClientState(ClientState.Reset); + } catch (error) { + logger.error("Chat reset error", error); + } }} /> ); diff --git a/employee-portal/src/lib/businessModules/chat/components/ReadConfirmations.tsx b/employee-portal/src/lib/businessModules/chat/components/ReadConfirmations.tsx deleted file mode 100644 index f0f1575cb138ffd2e5ecd55c4d055ee95c2edd1b..0000000000000000000000000000000000000000 --- a/employee-portal/src/lib/businessModules/chat/components/ReadConfirmations.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Avatar, Stack, Tooltip } from "@mui/joy"; -import { User } from "matrix-js-sdk"; - -interface ReadConfirmationsProps { - receiptUsers: (User | null)[]; - getImageUrl: (url?: string) => string | null; -} - -export function ReadConfirmations({ - receiptUsers, - getImageUrl, -}: Readonly<ReadConfirmationsProps>) { - return ( - <Stack direction="row"> - {receiptUsers.length > 0 && - receiptUsers.map( - (receiptUser) => - receiptUser?.displayName && ( - <Tooltip - key={receiptUser.userId} - title={receiptUser.displayName} - disablePortal - placement="bottom-start" - arrow - sx={{ - minHeight: "2.5rem", - minWidth: "6rem", - display: "flex", - justifyContent: "center", - alignItems: "center", - backgroundColor: "rgba(0, 0, 0, 0.7)", - }} - > - <Avatar - src={getImageUrl(receiptUser.avatarUrl) ?? undefined} - variant="outlined" - sx={{ - width: "1rem", - height: "1rem", - alignSelf: "flex-end", - flexDirection: "row", - }} - > - {receiptUser.displayName.charAt(0)} - </Avatar> - </Tooltip> - ), - )} - </Stack> - ); -} diff --git a/employee-portal/src/lib/businessModules/chat/components/UserPanel.tsx b/employee-portal/src/lib/businessModules/chat/components/UserPanel.tsx deleted file mode 100644 index 75c16bfe5a2da4c97ee54854346088094990b6e3..0000000000000000000000000000000000000000 --- a/employee-portal/src/lib/businessModules/chat/components/UserPanel.tsx +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { BaseModal } from "@eshg/lib-portal/components/BaseModal"; -import SettingsIcon from "@mui/icons-material/Settings"; -import { - Avatar, - Badge, - Button, - Divider, - Sheet, - Switch, - Typography, -} from "@mui/joy"; -import { User } from "matrix-js-sdk"; -import { useState } from "react"; - -import { useChat } from "@/lib/businessModules/chat/shared/ChatProvider"; -import { useUserSettings } from "@/lib/businessModules/chat/shared/hooks/useUserSettings"; - -interface UserPanelProps { - loggedInUser: User; - getImageUrl: (url?: string) => string | null; -} - -export function UserPanel({ - loggedInUser, - getImageUrl, -}: Readonly<UserPanelProps>) { - const [modalOpen, setModalOpen] = useState(false); - const { - userSettings: { - sharePresence, - showReadConfirmation, - showTypingNotification, - }, - } = useChat(); - const { - togglePresenceStatus, - toggleReadConfirmation, - toggleTypingNotifications, - } = useUserSettings(); - - return ( - <> - <Sheet sx={{ px: 1, borderRadius: 0 }}> - <Button - aria-label="Benutzereinstellungen" - onClick={() => setModalOpen(true)} - variant="soft" - sx={{ - backgroundColor: "transparent", - p: 0, - borderRadius: "50%", - "&:hover": { - backgroundColor: "transparent", - }, - }} - > - <Badge - anchorOrigin={{ vertical: "bottom", horizontal: "right" }} - badgeInset="18%" - variant="plain" - size="lg" - badgeContent={<SettingsIcon color="neutral" />} - sx={{ - backgroundColor: "transparent", - "--Badge-ring": "none", - }} - slotProps={{ - badge: { - sx: { px: 0, border: "none" }, - }, - }} - > - <Avatar - src={getImageUrl(loggedInUser?.avatarUrl) ?? undefined} - variant="outlined" - /> - </Badge> - </Button> - </Sheet> - <BaseModal - open={modalOpen} - onClose={() => setModalOpen(false)} - modalTitle={loggedInUser.displayName ?? ""} - > - <Sheet - variant="soft" - sx={{ - minHeight: "9rem", - backgroundColor: "transparent", - p: 0, - mt: 0, - }} - > - <Typography level="body-md" color="primary" mb={2}> - {loggedInUser.userId} - </Typography> - <Divider /> - <Typography - component="label" - mt={2} - endDecorator={ - <Switch - checked={sharePresence} - onChange={() => togglePresenceStatus(sharePresence)} - /> - } - > - Online-Status senden - </Typography> - <Typography - component="label" - mt={2} - endDecorator={ - <Switch - checked={showReadConfirmation} - onChange={() => toggleReadConfirmation(showReadConfirmation)} - /> - } - > - Lesebestätigungen anzeigen - </Typography> - <Typography - component="label" - mt={2} - endDecorator={ - <Switch - checked={showTypingNotification} - onChange={() => - toggleTypingNotifications(showTypingNotification) - } - /> - } - > - Eingabebenachrichtigungen anzeigen - </Typography> - </Sheet> - </BaseModal> - </> - ); -} 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 4795c36389e70d18608e6d09de8988f36cdbccb7..a5cb2f5f648fea2330097c4a13f12363c8573d9b 100644 --- a/employee-portal/src/lib/businessModules/chat/components/chatPanel/ChatPanel.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/chatPanel/ChatPanel.tsx @@ -6,7 +6,7 @@ import { Alert, AlertProps } from "@eshg/lib-portal/components/Alert"; import { Box } from "@mui/joy"; import { useEffect, useState } from "react"; -import { isNonNullish } from "remeda"; +import { isNonNullish, isShallowEqual, isStrictEqual } from "remeda"; import { chatColumnHeaderHeight } from "@/lib/businessModules/chat/components/ChatColumnHeaderWrapper"; import { ChatIllustrationBackground } from "@/lib/businessModules/chat/components/ChatIllustrationBackground"; @@ -99,12 +99,21 @@ export function ChatPanel({ const data = await getChatUserDirectory(matrixClient); if (data.results.length) { const users = data.results - .filter( - (user) => - !!user && - user.user_id !== loggedInUserId && - !!user.display_name, - ) + .filter((user) => { + const isLoggedInUser = isStrictEqual( + user.user_id, + loggedInUserId, + ); + + const isAdmin = isShallowEqual( + user.display_name?.toUpperCase(), + "ADMIN", + ); + + return ( + isNonNullish(user.display_name) && !isLoggedInUser && !isAdmin + ); + }) .map((u) => ({ ...u, department: departmentInfo?.name })); setUserList(users); diff --git a/employee-portal/src/lib/businessModules/chat/components/infoPanel/AddChatMember.tsx b/employee-portal/src/lib/businessModules/chat/components/infoPanel/AddChatMember.tsx index f90cda98a829c7dd414f2210172bffa4618ad7f3..6b491d5194f58df4e1ba94873b1e5497139476b9 100644 --- a/employee-portal/src/lib/businessModules/chat/components/infoPanel/AddChatMember.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/infoPanel/AddChatMember.tsx @@ -12,6 +12,7 @@ import { filter, isEmpty, isNonNullish, + isShallowEqual, isStrictEqual, map, pipe, @@ -52,15 +53,22 @@ export function AddChatMember({ const usersToInvite = pipe( data.results, filter((user) => { - const isLoggedInUser = - isStrictEqual(user.user_id, loggedInUserId) && - isNonNullish(user.display_name); - + const isLoggedInUser = isStrictEqual(user.user_id, loggedInUserId); const isDuplicated = roomMembers?.some((i) => isStrictEqual(i.member.userId, user.user_id), ); - return !isLoggedInUser && !isDuplicated; + const isAdmin = isShallowEqual( + user.display_name?.toUpperCase(), + "ADMIN", + ); + + return ( + isNonNullish(user.display_name) && + !isLoggedInUser && + !isDuplicated && + !isAdmin + ); }), map((user) => ({ ...user, 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 31a96726dbde4ffec31b913aa1450b318bde8a3f..940a9445cdefff366581170b2bf896f459b72f58 100644 --- a/employee-portal/src/lib/businessModules/chat/components/secureBackup/CreateBackupSidebar.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/secureBackup/CreateBackupSidebar.tsx @@ -125,10 +125,10 @@ export function CreateBackupSidebar({ authUploadDeviceSigningKeys, ); setClientState(ClientState.Prepared); - snackbar.confirmation("Secure Backup success"); + snackbar.confirmation("Sicherheitsbackup erfolgreich eingerichtet"); } catch (e) { handleClose(); - snackbar.error("Secure Backup failed"); + snackbar.error("Einrichten des Sicherheitsbackups fehlgeschlagen"); logger.error(e); } } 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 86fabbec73874393548bf467c36996335482f298..b1ae25e93631e4164a997e8be67a9c2dbd23ee20 100644 --- a/employee-portal/src/lib/businessModules/chat/components/secureBackup/ResetBackupModal.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/secureBackup/ResetBackupModal.tsx @@ -9,7 +9,7 @@ import { } from "@eshg/lib-portal/components/BaseModal"; import { Button, Stack, Typography } from "@mui/joy"; -import { deleteBackup } from "@/lib/businessModules/chat/matrix/secretStorage"; +import { deleteKeyBackup } from "@/lib/businessModules/chat/matrix/secretStorage"; import { useChatClientContext } from "@/lib/businessModules/chat/shared/ChatClientProvider"; import { ClientState } from "@/lib/businessModules/chat/shared/enums"; import { logger } from "@/lib/businessModules/chat/shared/helpers"; @@ -21,11 +21,13 @@ export function ResetBackupModal( async function handleResetAllClick() { try { - const backupInfo = await matrixClient.getKeyBackupVersion(); - await deleteBackup(matrixClient, backupInfo); + const crypto = matrixClient.getCrypto(); + if (!crypto) throw new Error("CryptoApi is undefined"); + + const backupInfo = await crypto.getKeyBackupInfo(); matrixClient.stopClient(); - await matrixClient.logout(); - setClientState(ClientState.Restart); + await deleteKeyBackup(matrixClient, backupInfo); + setClientState(ClientState.Reset); } catch (error) { setClientState(ClientState.Error); logger.error("Reset Everything error", error); 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 bce8a8672d41f86bfd6adeacff184a3cd649089d..4b6b59aa73ce8b82e87dc3378d3b737716ca628a 100644 --- a/employee-portal/src/lib/businessModules/chat/components/secureBackup/RestoreBackupSidebar.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/secureBackup/RestoreBackupSidebar.tsx @@ -14,7 +14,7 @@ import { SecureBackupContent } from "@/lib/businessModules/chat/components/secur import { ResetBackupModal } from "@/lib/businessModules/chat/components/secureBackup/ResetBackupModal"; import { fetchBackupInfo } from "@/lib/businessModules/chat/matrix/crypto"; import { - restoreKeyBackupWithSecretStorage, + loadBackupKeyFromSecretStorage, validateAccessSecretStorage, } from "@/lib/businessModules/chat/matrix/secretStorage"; import { useChatClientContext } from "@/lib/businessModules/chat/shared/ChatClientProvider"; @@ -73,19 +73,14 @@ export function RestoreBackupSidebar({ async function handleSubmit(values: InitialValues) { try { - const { backupInfo, backupKeyStored } = + const { keyBackupInfo, has4SBackupKeyStored } = await fetchBackupInfo(matrixClient); - if (!backupInfo) { - throw new Error("No backup Info"); + if (!keyBackupInfo || !has4SBackupKeyStored) { + throw new Error("No backupInfo"); } - await restoreKeyBackupWithSecretStorage( - matrixClient, - backupInfo, - backupKeyStored, - values.passphrase, - ); + await loadBackupKeyFromSecretStorage(matrixClient, values.passphrase); setClientState(ClientState.Prepared); snackbar.confirmation("Ihr Gerät wurde nun verifiziert"); } catch (e) { diff --git a/employee-portal/src/lib/businessModules/chat/components/secureBackup/SSOAuthModal.tsx b/employee-portal/src/lib/businessModules/chat/components/secureBackup/SSOAuthModal.tsx index c0a066d1ab22cfbeaf89b74ce2f91dc6b3595706..1eda8dedcd96e44ded000274a076682d72206312 100644 --- a/employee-portal/src/lib/businessModules/chat/components/secureBackup/SSOAuthModal.tsx +++ b/employee-portal/src/lib/businessModules/chat/components/secureBackup/SSOAuthModal.tsx @@ -90,7 +90,7 @@ export function SSOAuthModal({ values }: SSOAuthModalProps) { useEffect(() => { function onMessage(e: MessageEvent) { - logger.debug("On Window Message", e.data); + logger.debug("SSOAuthModal - On Window Message", e.data); } window.addEventListener("message", onMessage); diff --git a/employee-portal/src/lib/businessModules/chat/matrix/crypto.ts b/employee-portal/src/lib/businessModules/chat/matrix/crypto.ts index 844100857e3aa18ee9235b9e222ca146a076f020..e887c3a43e7bcace8bdab4983fdaeda19afc5088 100644 --- a/employee-portal/src/lib/businessModules/chat/matrix/crypto.ts +++ b/employee-portal/src/lib/businessModules/chat/matrix/crypto.ts @@ -3,45 +3,34 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { MatrixClient, decodeBase64 } from "matrix-js-sdk"; +import { MatrixClient } from "matrix-js-sdk"; import { logger } from "@/lib/businessModules/chat/shared/helpers"; -interface RustCryptoArgs { - rustCryptoStoreKey?: Uint8Array; - rustCryptoStorePassword?: string; -} - -export function getRustCryptoStoreArgs(pickleKey: string | null) { - const rustCryptoArgs: RustCryptoArgs = {}; - if (pickleKey) { - // The pickleKey, if provided can be used for the crypto store. - if (pickleKey.length === 43) { - rustCryptoArgs.rustCryptoStoreKey = decodeBase64(pickleKey); - } else { - rustCryptoArgs.rustCryptoStorePassword = pickleKey; - } - } - return rustCryptoArgs; -} - export async function fetchBackupInfo(matrixClient: MatrixClient) { - const backupInfo = await matrixClient.getKeyBackupVersion(); - const has4S = await matrixClient.secretStorage.hasKey(); - const backupKeyStored = has4S + const crypto = matrixClient.getCrypto(); + if (!crypto) throw new Error("CryptoApi is undefined"); + + const keyBackupInfo = await crypto.getKeyBackupInfo(); + const has4SKey = await matrixClient.secretStorage.hasKey(); + const has4SBackupKeyStored = has4SKey ? !!(await matrixClient.isKeyBackupKeyStored()) : false; - logger.debug("fetchBackupInfo", { backupKeyStored, backupInfo, has4S }); + logger.debug("fetchBackupInfo", { + has4SBackupKeyStored, + keyBackupInfo, + has4SKey, + }); - return { backupInfo, has4S, backupKeyStored }; + return { keyBackupInfo, has4SKey, has4SBackupKeyStored }; } export async function getBackupKeyStatus(matrixClient: MatrixClient) { const crypto = matrixClient.getCrypto(); if (!crypto) return; - const secretStorage = matrixClient.secretStorage; + const serverSideSecretStorage = matrixClient.secretStorage; const isKeyBackupKeyStored = await matrixClient.isKeyBackupKeyStored(); @@ -49,7 +38,7 @@ export async function getBackupKeyStatus(matrixClient: MatrixClient) { const backupKeyFromCache = await crypto.getSessionBackupPrivateKey(); const backupKeyCached = !!backupKeyFromCache; const backupKeyWellFormed = backupKeyFromCache instanceof Uint8Array; - const secretStorageKeyInAccount = await secretStorage.hasKey(); + const secretStorageKeyInAccount = await serverSideSecretStorage.hasKey(); const secretStorageReady = await crypto.isSecretStorageReady(); return { @@ -93,10 +82,52 @@ export async function getCrossSigningStatus(matrixClient: MatrixClient) { } export async function isDeviceVerified(client: MatrixClient) { + const crypto = client.getCrypto(); + if (!crypto) { + logger.warn("Unable to verify device: RustCrypto is not yet initialized."); + return false; + } + const deviceId = client.getDeviceId(); - const trustLevel = await client - .getCrypto() - ?.getDeviceVerificationStatus(client.getSafeUserId(), deviceId ?? ""); + if (!deviceId) { + logger.warn("Unable to verify device: MatrixClient is missing deviceId."); + return false; + } - return trustLevel?.crossSigningVerified ?? null; + const trustLevel = await crypto.getDeviceVerificationStatus( + client.getSafeUserId(), + deviceId, + ); + if (!trustLevel) { + logger.warn( + "Unable to verify device: Device is unknown, or has not published any encryption keys.", + ); + return false; + } + + return trustLevel.crossSigningVerified; +} + +/** + * Generates a 256-bit hash (SHA-256) from the combined string of the user's ID and device ID. + * This hash is returned as a Uint8Array representing the storage key. + */ +export async function createStorageKey(selfUserId: string, deviceId: string) { + const combinedString = `${selfUserId}:${deviceId}`; + + const encoder = new TextEncoder(); + const data = encoder.encode(combinedString); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = new Uint8Array(hashBuffer); + + return hashArray; +} + +/** + * Generates a random 256-bit storage key (32 bytes) using the cryptographic random number generator. + */ +export function generateStorageKey() { + const key = new Uint8Array(32); + crypto.getRandomValues(key); + return key; } diff --git a/employee-portal/src/lib/businessModules/chat/matrix/idb.ts b/employee-portal/src/lib/businessModules/chat/matrix/idb.ts deleted file mode 100644 index d1b8539a9478a03be0ee63adef10b580bc8f2e1a..0000000000000000000000000000000000000000 --- a/employee-portal/src/lib/businessModules/chat/matrix/idb.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/prefer-promise-reject-errors */ -import { logger } from "@/lib/businessModules/chat/shared/helpers"; - -/** - * Retrieves the IndexedDB factory object. - */ -export function getIDBFactory(): IDBFactory | undefined { - return self?.indexedDB ? self.indexedDB : window.indexedDB; -} - -let idb: IDBDatabase | null = null; -const dbName = "matrix-account"; - -/** - * Loads an item from an IndexedDB table within the underlying `matrix-react-sdk` database. - * - * If IndexedDB access is not supported in the environment, an error is thrown. - */ -async function idbInit(): Promise<void> { - if (!getIDBFactory()) { - throw new Error("IndexedDB not available"); - } - idb = await new Promise((resolve, reject) => { - const request = getIDBFactory()!.open(dbName, 1); - request.onerror = (): void => { - reject(request.error); - }; - request.onsuccess = (): void => { - resolve(request.result); - }; - request.onupgradeneeded = (): void => { - const db = request.result; - db.createObjectStore("pickleKey"); - db.createObjectStore("account"); - }; - }); -} - -/** - * Saves data to an IndexedDB table within the underlying `matrix-react-sdk` database. - * - * If IndexedDB access is not supported in the environment, an error is thrown. - */ -export async function idbLoad( - table: string, - key: string | string[], -): Promise<any> { - if (!idb) { - await idbInit(); - } - return new Promise((resolve, reject) => { - const txn = idb!.transaction([table], "readonly"); - txn.onerror = reject; - - const objectStore = txn.objectStore(table); - const request = objectStore.get(key); - request.onerror = (): void => { - reject(request.error); - }; - request.onsuccess = (): void => { - resolve(request.result); - }; - }); -} - -/** - * Saves data to an IndexedDB table within the underlying `matrix-react-sdk` database. - * - * If IndexedDB access is not supported in the environment, an error is thrown. - */ -export async function idbSave( - table: string, - key: string | string[], - data: any, -): Promise<void> { - if (!idb) { - await idbInit(); - } - return new Promise((resolve, reject) => { - const txn = idb!.transaction([table], "readwrite"); - txn.onerror = reject; - - const objectStore = txn.objectStore(table); - const request = objectStore.put(data, key); - request.onerror = (): void => { - reject(request.error); - }; - request.onsuccess = (): void => { - resolve(); - }; - }); -} - -/** - * Deletes a record from an IndexedDB table within the underlying `matrix-react-sdk` database. - * - * If IndexedDB access is not supported in the environment, an error is thrown. - */ -export async function idbDelete( - table: string, - key: string | string[], -): Promise<void> { - if (!idb) { - await idbInit(); - } - return new Promise((resolve, reject) => { - const txn = idb!.transaction([table], "readwrite"); - txn.onerror = reject; - - const objectStore = txn.objectStore(table); - const request = objectStore.delete(key); - request.onerror = (): void => { - reject(request.error); - }; - request.onsuccess = (): void => { - resolve(); - }; - }); -} - -/** - * Clear all records from an IndexedDB table within the underlying `matrix-react-sdk` database. - * - * If IndexedDB access is not supported in the environment, an error is thrown. - */ -export async function idbClearTable(table: string): Promise<void> { - if (!idb) { - await idbInit(); - } - return new Promise((resolve, reject) => { - const txn = idb!.transaction([table], "readwrite"); - txn.onerror = reject; - - const objectStore = txn.objectStore(table); - const request = objectStore.clear(); - request.onerror = (): void => { - reject(request.error); - }; - request.onsuccess = (): void => { - resolve(); - }; - }); -} - -export async function idbDeleteDb(): Promise<void> { - let indexedDB: IDBFactory | undefined; - try { - indexedDB = getIDBFactory(); - if (!indexedDB) return; - } catch { - return; - } - - const prom = new Promise((resolve) => { - if (idb) { - idb.close(); - } - const request = indexedDB.deleteDatabase(dbName); - request.onerror = (): void => { - resolve(0); - logger.info("Account DB deletion failed"); - }; - request.onsuccess = (): void => { - idb = null; - resolve(0); - logger.info("Account DB deleted"); - }; - request.onblocked = (): void => { - request.result.close(); - logger.info("Account DB is blocked"); - }; - }); - await prom; -} diff --git a/employee-portal/src/lib/businessModules/chat/matrix/login.ts b/employee-portal/src/lib/businessModules/chat/matrix/login.ts index e08bfa758072c48b62244b7d3b86d4d59ea617a9..d47466745fef7b40aacc29e9ca151672cab05e03 100644 --- a/employee-portal/src/lib/businessModules/chat/matrix/login.ts +++ b/employee-portal/src/lib/businessModules/chat/matrix/login.ts @@ -3,13 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ApiUser } from "@eshg/base-api"; -import { MatrixClient, SSOAction, createClient } from "matrix-js-sdk"; +import { MatrixClient, createClient } from "matrix-js-sdk"; +import { isStrictEqual } from "remeda"; -import { - createPickleKey, - getPickleKey, -} from "@/lib/businessModules/chat/matrix/pickling"; import { clearCachedCredentials, clearMatrixStores, @@ -18,208 +14,118 @@ import { } from "@/lib/businessModules/chat/matrix/tokens"; import { logger } from "@/lib/businessModules/chat/shared/helpers"; -export interface ILoginParams { - baseUrl: string; - selfUser: ApiUser; +export function fetchFn( + input: RequestInfo | URL, + init?: RequestInit, + deviceId?: string, +): Promise<Response> { + const headers = deviceId + ? { + ...init?.headers, + "X-Forwarded-Matrix-Device-Id": deviceId, + } + : init?.headers; + + return fetch(input, { + ...init, + credentials: "same-origin", + headers, + }); } -async function healthcheckHomeserver(matrixClient: MatrixClient) { - try { - const response = await fetch( - `${matrixClient.getHomeserverUrl()}/_matrix/client/versions`, +export async function getCredentials( + baseUrl: string, + selfUserChatUserId?: string, +) { + let credentials = getCachedCredentials(); + + if ( + !hasValidCachedCredentials( + credentials.userId, + credentials.deviceId, + selfUserChatUserId, + ) + ) { + logger.debug("Clear cache and Login to synapse and get new deviceId"); + const temporaryMatrixClient = createTemporaryMatrixClient(baseUrl); + await clearMatrixStores(); + clearCachedCredentials(); + credentials = await requestCredentials(temporaryMatrixClient); + persistCredentials(credentials); + } else { + logger.debug("Login to synapse with cached deviceId"); + const temporaryMatrixClient = createTemporaryMatrixClient( + baseUrl, + credentials.deviceId, ); - if (!response.ok) { - throw new Error("Synapse is unavailable"); - } - return true; - } catch (error) { - logger.error("Synapse health check failed:", error); - return false; + await requestCredentials(temporaryMatrixClient); } -} -function startSingleSignOn( - matrixClient: MatrixClient, - loginType: "sso" | "cas" = "sso", - idpId?: string, - action?: SSOAction, -) { - logger.debug("Starting Synapse SSO login flow."); - const callbackUrl = new URL(window.location.href).toString(); - - window.location.href = matrixClient.getSsoLoginUrl( - callbackUrl, - loginType, - idpId, - action, - ); -} - -function extractSSOFailureMessage() { - const urlParams = new URLSearchParams(window.location.search); - return urlParams.get("synapseError"); -} - -function extractLoginToken() { - const urlParams = new URLSearchParams(window.location.search); - return urlParams.get("loginToken"); + return credentials; } -async function handleSSOLogin(matrixClient: MatrixClient) { - const synapseHealthy = await healthcheckHomeserver(matrixClient); - if (!synapseHealthy) { - logger.error("Synapse is offline, aborting login to chat."); - return undefined; - } - - const ssoRedirectFailure = extractSSOFailureMessage(); - if (ssoRedirectFailure) { - logger.error("Synapse SSO redirect failed, aborting login to chat."); - return undefined; - } - - const loginToken = extractLoginToken(); - if (!loginToken) { - // if token not found, start SSO flow - void startSingleSignOn(matrixClient); - - // Return undefined to stop login process - return undefined; +export async function validateCachedCredentials( + selfUserChatUserId?: string, + initialValidation = false, +) { + logger.debug("Validate cached credentials", selfUserChatUserId); + const credentials = getCachedCredentials(); + + if (initialValidation && !credentials.deviceId && !credentials.userId) return; + + if ( + !hasValidCachedCredentials( + credentials.userId, + credentials.deviceId, + selfUserChatUserId, + ) + ) { + await clearMatrixStores(); + clearCachedCredentials(); } - - logger.debug( - "Synapse SSO flow finished successfully, performing login to matrix chat with loginToken.", - ); - return matrixClient.loginWithToken(loginToken); -} - -function verifyCachedUserId(chatUsername?: string, userId?: string) { - return userId?.toLowerCase() === chatUsername?.toLowerCase(); -} - -async function createLoggedInClient(payload: ILoginParams) { - // Create guest client - const matrixClient = createClient({ - baseUrl: payload.baseUrl, - }); - - // Clear stores - await clearCachedCredentials(); - void clearMatrixStores(); - - // Start SSO, redirect the page to receive the login token. - // Once the token is received in the search parameters, we can initiate the login process. - // desc: https://spec.matrix.org/v1.11/client-server-api/#client-login-via-sso - const response = await handleSSOLogin(matrixClient); - - // If response is undefined that means the SSO process is ongoing. - // Return `undefined` here and await redirection with the login token, - // otherwise, a guest client will be returned. - if (!response) return undefined; - - return matrixClient; } -async function createCachedClient(payload: ILoginParams) { - const { accessToken, deviceId, userId } = await getCachedCredentials(); - - const isMatchedUser = verifyCachedUserId( - payload.selfUser.externalChatUsername, - userId, - ); - - if (!isMatchedUser) { - logger.debug("No match found with cached user."); - } - - // Create client based on stored credentials - if (accessToken && deviceId && userId && isMatchedUser) { - logger.debug("Prepare matrix client using cached credentials."); - - return createClient({ - baseUrl: payload.baseUrl, - deviceId, - userId, - accessToken, - }); +function hasValidCachedCredentials( + userId?: string, + deviceId?: string, + selfUserChatUserId?: string, +) { + if (!deviceId || !userId) { + logger.debug("deviceId or userId not found in cache"); + return false; } - return undefined; -} - -/** - * Create and store a pickle key for encrypting react-sdk-crypto data.. - * - * Returns the pickle key which can be used for the rust crypto store. - */ - -async function initPickleKey(userId: string, deviceId: string) { - let pickleKey = await getPickleKey(userId, deviceId); - - if (!pickleKey) { - pickleKey = await createPickleKey(userId, deviceId); - if (pickleKey) { - logger.debug("Created pickle key"); - } else { - logger.debug("Pickle key not created"); - } + if (!isStrictEqual(selfUserChatUserId, userId)) { + logger.debug("Cached userId is not matching logged-in user."); + return false; } - - return pickleKey; + return true; } -async function createInitialClient(payload: ILoginParams) { - let matrixClient = await createCachedClient(payload); +export async function requestCredentials(matrixClient: MatrixClient) { + logger.debug("Requesting userId and deviceid from matrix whoami endpoint"); - // Send login request if credentials were not stored. - if (!matrixClient) { - matrixClient = await createLoggedInClient(payload); - } - - return matrixClient; -} - -async function getCredentials(matrixClient: MatrixClient) { try { - const whoami = await matrixClient.whoami(); - const accessToken = matrixClient.getAccessToken() ?? undefined; - - if (!accessToken) { - throw new Error("Unable to retrieve access token"); - } - - if (!whoami.device_id || !whoami.user_id) { + const whoamiResponse = await matrixClient.whoami(); + if (!whoamiResponse.device_id || !whoamiResponse.user_id) { throw new Error("Unable to retrieve whoami data"); } - - const pickleKey = await initPickleKey(whoami.user_id, whoami.device_id); - return { - accessToken, - userId: whoami.user_id, - deviceId: whoami.device_id, - pickleKey, + userId: whoamiResponse.user_id, + deviceId: whoamiResponse.device_id, }; } catch (error) { - logger.softError("Client verification failed"); + logger.softError("Unable to get client credentials"); throw error; } } -export async function chatLogin(baseUrl: string, selfUser: ApiUser) { - const matrixClient = await createInitialClient({ - baseUrl, - selfUser, +export function createTemporaryMatrixClient( + baseUrl: string, + deviceId?: string, +) { + return createClient({ + baseUrl: baseUrl, + fetchFn: (input, init) => fetchFn(input, init, deviceId), }); - - if (!matrixClient) { - logger.softError("Temporary client creation failed"); - return; - } - - // Verify created client and get credentials - const credentials = await getCredentials(matrixClient); - - await persistCredentials(credentials); - return credentials; } diff --git a/employee-portal/src/lib/businessModules/chat/matrix/pickling.ts b/employee-portal/src/lib/businessModules/chat/matrix/pickling.ts deleted file mode 100644 index 2c76f6d4003129fa84576440ce8d56ae976933e2..0000000000000000000000000000000000000000 --- a/employee-portal/src/lib/businessModules/chat/matrix/pickling.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { encodeUnpaddedBase64 } from "matrix-js-sdk/lib/base64"; - -import { - idbClearTable, - idbLoad, - idbSave, -} from "@/lib/businessModules/chat/matrix/idb"; - -export interface EncryptedPickleKey { - /** The encrypted payload. */ - encrypted?: BufferSource; - - /** Initialisation vector for the encryption. */ - iv?: BufferSource; - - /** The encryption key which was used to encrypt the payload. */ - cryptoKey?: CryptoKey; -} - -/** - * Get a previously stored pickle key. The pickle key is used for - * encrypting react-sdk-crypto data. - */ -export async function getPickleKey( - userId: string, - deviceId: string, -): Promise<string | null> { - let data: EncryptedPickleKey | undefined; - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - data = await idbLoad("pickleKey", [userId, deviceId]); - } catch (e) { - // eslint-disable-next-line no-console - console.error("idbLoad for pickleKey failed", e); - } - - return (await buildAndEncodePickleKey(data, userId, deviceId)) ?? null; -} - -/** - * Create and store a pickle key for encrypting libolm objects. - */ -export async function createPickleKey( - userId: string, - deviceId: string, -): Promise<string | null> { - const randomArray = new Uint8Array(32); - crypto.getRandomValues(randomArray); - const data = await encryptPickleKey(randomArray, userId, deviceId); - if (data === undefined) { - // no crypto support - return null; - } - - try { - await idbClearTable("pickleKey"); - await idbSave("pickleKey", [userId, deviceId], data); - } catch { - return null; - } - return encodeUnpaddedBase64(randomArray); -} - -/** - * Calculates the `additionalData` for the AES-GCM key used by the pickling processes. This - * additional data is *not* encrypted, but *is* authenticated. The additional data is constructed - * from the user ID and device ID provided. - * - * The later-constructed pickle key is used to decrypt values, such as access tokens, from IndexedDB. - */ -function getPickleAdditionalData(userId: string, deviceId: string): Uint8Array { - const additionalData = new Uint8Array(userId.length + deviceId.length + 1); - for (let i = 0; i < userId.length; i++) { - additionalData[i] = userId.charCodeAt(i); - } - additionalData[userId.length] = 124; - for (let i = 0; i < deviceId.length; i++) { - additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i); - } - return additionalData; -} - -/** - * Encrypt the given pickle key, ready for storage in the database. - */ -async function encryptPickleKey( - pickleKey: Uint8Array, - userId: string, - deviceId: string, -): Promise<EncryptedPickleKey | undefined> { - if (!crypto?.subtle) { - return undefined; - } - const cryptoKey = await crypto.subtle.generateKey( - { name: "AES-GCM", length: 256 }, - false, - ["encrypt", "decrypt"], - ); - const iv = new Uint8Array(32); - crypto.getRandomValues(iv); - - const additionalData = getPickleAdditionalData(userId, deviceId); - const encrypted = await crypto.subtle.encrypt( - { name: "AES-GCM", iv, additionalData }, - cryptoKey, - pickleKey, - ); - return { encrypted, iv, cryptoKey }; -} - -/** - * Decrypts the provided data into a pickle key and base64-encodes it ready for use elsewhere. - * - * If `data` is undefined in part or in full, returns undefined. - * - * If crypto functions are not available, returns undefined regardless of input. - * - */ -async function buildAndEncodePickleKey( - data: EncryptedPickleKey | undefined, - userId: string, - deviceId: string, -): Promise<string | undefined> { - if (!crypto?.subtle) { - return undefined; - } - if (!data?.encrypted || !data.iv || !data.cryptoKey) { - return undefined; - } - - try { - const additionalData = getPickleAdditionalData(userId, deviceId); - const pickleKeyBuf = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: data.iv, additionalData }, - data.cryptoKey, - data.encrypted, - ); - if (pickleKeyBuf) { - return encodeUnpaddedBase64(pickleKeyBuf); - } - } catch { - // eslint-disable-next-line no-console - console.error("Error decrypting pickle key"); - } - - return undefined; -} diff --git a/employee-portal/src/lib/businessModules/chat/matrix/secretStorage.ts b/employee-portal/src/lib/businessModules/chat/matrix/secretStorage.ts index d1ff608c1ddacb8176122cb37a459b71da742803..ddd418b3df2ffb2111365b5aa5f499d7a8829cc0 100644 --- a/employee-portal/src/lib/businessModules/chat/matrix/secretStorage.ts +++ b/employee-portal/src/lib/businessModules/chat/matrix/secretStorage.ts @@ -10,7 +10,7 @@ import { logger } from "@/lib/businessModules/chat/shared/helpers"; import { getSecretStorageKey } from "./cryptoCallbacks"; -export async function deleteBackup( +export async function deleteKeyBackup( matrixClient: MatrixClient, backupInfo?: KeyBackupInfo | null, ) { @@ -31,55 +31,43 @@ export async function deleteBackup( } } -export async function restoreKeyBackupWithCache( - matrixClient: MatrixClient, - backupInfo?: KeyBackupInfo | null, -) { +export async function restoreKeyBackup(matrixClient: MatrixClient) { let handled = false; + try { + const crypto = matrixClient.getCrypto(); + if (!crypto) throw new Error("CryptoApi is undefined"); - if (backupInfo) { - try { - const gotCache = await matrixClient.restoreKeyBackupWithCache( - undefined /* targetRoomId */, - undefined /* targetSessionId */, - backupInfo, - ); - if (gotCache) { - handled = true; - logger.debug("RestoreKeyBackup: found cached backup key"); - } - } catch (e) { - logger.debug("restoreKeyBackupWithCache failed", e); + const keyBackup = await crypto.restoreKeyBackup(); + if (keyBackup) { + handled = true; + logger.debug("Key backup restored successfully"); } + } catch (e) { + logger.softError("Failed to restore key backup", e); } - return handled; } -export async function restoreKeyBackupWithSecretStorage( +export async function loadBackupKeyFromSecretStorage( matrixClient: MatrixClient, - backupInfo?: KeyBackupInfo | null, - backupKeyStored?: boolean, passphrase?: string, ) { let handled = false; - if (backupKeyStored) { - try { - if (backupInfo) { - await accessSecretStorage(matrixClient, passphrase); - const keyBackup = await matrixClient.restoreKeyBackupWithSecretStorage( - backupInfo, - undefined, - undefined, - ); - handled = true; - logger.debug("restoreKeyBackupWithSecretStorage", { keyBackup }); - } - } catch (e) { - logger.softError("restoreKeyBackupWithSecretStorage failed"); - throw e; + try { + await accessSecretStorage(matrixClient, passphrase); + const crypto = matrixClient.getCrypto(); + if (!crypto) throw new Error("CryptoApi is undefined"); + + await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); + const keyBackup = await crypto.restoreKeyBackup(); + if (keyBackup) { + handled = true; + logger.debug("Key backup successfully loaded from secret storage"); } + } catch (e) { + logger.softError("Failed to load key backup from secret storage"); + throw e; } return handled; @@ -94,7 +82,7 @@ export async function setupNewSecretStorage( const crypto = matrixClient.getCrypto(); if (!crypto) { throw new Error( - "End-to-end encryption is disabled - unable to access secret storage.", + "SetupNewSecretStorage: End-to-end encryption is disabled - unable to create secret storage.", ); } @@ -125,7 +113,7 @@ export async function accessSecretStorage( const crypto = matrixClient.getCrypto(); if (!crypto) { throw new Error( - "End-to-end encryption is disabled - unable to access secret storage.", + "AccessSecretStorage: End-to-end encryption is disabled - unable to access secret storage.", ); } diff --git a/employee-portal/src/lib/businessModules/chat/matrix/tokens.ts b/employee-portal/src/lib/businessModules/chat/matrix/tokens.ts index 1be968e6a377c92605f90fbf61aaae4948549a64..008fa69846218ea4252e1ac86a2ea1588cff11e6 100644 --- a/employee-portal/src/lib/businessModules/chat/matrix/tokens.ts +++ b/employee-portal/src/lib/businessModules/chat/matrix/tokens.ts @@ -4,54 +4,25 @@ */ import { createClient } from "matrix-js-sdk"; -import { - IEncryptedPayload, - decryptAES, - encryptAES, -} from "matrix-js-sdk/lib/crypto/aes"; +import { isNonNullish } from "remeda"; -import { - idbClearTable, - idbDeleteDb, - idbLoad, - idbSave, -} from "@/lib/businessModules/chat/matrix/idb"; -import { getPickleKey } from "@/lib/businessModules/chat/matrix/pickling"; import { IStoredCredentials } from "@/lib/businessModules/chat/shared/types"; -const ACCESS_TOKEN_STORAGE_KEY = "mx_access_token"; const USER_ID_STORAGE_KEY = "mx_user_id"; const DEVICE_ID_STORAGE_KEY = "mx_device_id"; -export const ACCESS_TOKEN_IV = "access_token"; - -export function getIDBFactory(): IDBFactory | undefined { - return self?.indexedDB ? self.indexedDB : window.indexedDB; -} - -export async function getCachedCredentials() { - let accessToken = await getCachedAccessToken(ACCESS_TOKEN_STORAGE_KEY); +export function getCachedCredentials() { const deviceId = localStorage.getItem(DEVICE_ID_STORAGE_KEY) ?? undefined; const userId = localStorage.getItem(USER_ID_STORAGE_KEY) ?? undefined; - let pickleKey: string | undefined; - - if (deviceId && userId) { - pickleKey = (await getPickleKey(userId, deviceId)) ?? undefined; - } - - accessToken = await tryDecryptToken(pickleKey, accessToken, ACCESS_TOKEN_IV); - - return { accessToken, deviceId, userId }; + return { deviceId, userId }; } -export async function persistCredentials( - credentials: Partial<IStoredCredentials>, -) { - if (credentials.accessToken) { - await cacheAccessToken(credentials); - } +export function getIDBFactory(): IDBFactory | undefined { + return self?.indexedDB ? self.indexedDB : window.indexedDB; +} +export function persistCredentials(credentials: Partial<IStoredCredentials>) { if (localStorage) { if (credentials.deviceId) { localStorage.setItem(DEVICE_ID_STORAGE_KEY, credentials.deviceId); @@ -69,158 +40,8 @@ export function clearLocalStorage() { } } -export async function clearCachedCredentials() { +export function clearCachedCredentials() { clearLocalStorage(); - await idbClearTable("pickleKey"); - await idbClearTable("account"); -} - -export async function deleteCachedCredentials() { - try { - clearLocalStorage(); - await idbDeleteDb(); - } catch { - // eslint-disable-next-line no-console - console.warn("Cached credentials were not cleared"); - } -} - -/** - * Retrieve a token, as stored by `persistCredentials` - * Attempts to migrate token from localStorage to idb - */ -async function getCachedAccessToken(storageKey: string) { - let token: IEncryptedPayload | string | undefined; - - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - token = await idbLoad("account", storageKey); - } catch (e) { - // eslint-disable-next-line no-console - console.error(`idbLoad failed to read: ${storageKey}`, e); - } - - if (!token) { - token = localStorage.getItem(storageKey) ?? undefined; - if (token) { - try { - // try to migrate access token to IndexedDB if we can - await idbSave("account", storageKey, token); - localStorage.removeItem(storageKey); - } catch (e) { - // eslint-disable-next-line no-console - console.error( - `migration of token ${storageKey} to IndexedDB failed`, - e, - ); - } - } - } - return token; -} - -async function cacheAccessToken(credentials: Partial<IStoredCredentials>) { - const { accessToken, deviceId, userId } = credentials; - - if (deviceId && userId && accessToken) { - const pickleKey = await getPickleKey(userId, deviceId); - - if (pickleKey) { - let encryptedAccessToken: IEncryptedPayload | null = null; - - try { - const aesKey = await pickleKeyToAesKey(pickleKey); - encryptedAccessToken = await encryptAES( - accessToken, - aesKey, - ACCESS_TOKEN_IV, - ); - aesKey.fill(0); // needs to zero it after using - } catch { - // eslint-disable-next-line no-console - console.error("Could not encrypt access token"); - } - - try { - // save either the encrypted access token, or the plain access - // token if we were unable to encrypt (e.g. if the browser doesn't - // have WebCrypto). - await idbSave( - "account", - ACCESS_TOKEN_STORAGE_KEY, - encryptedAccessToken ?? accessToken, - ); - } catch { - localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, accessToken); - } - } else { - try { - await idbSave("account", ACCESS_TOKEN_STORAGE_KEY, accessToken); - } catch { - localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, accessToken); - } - } - } -} - -/** - * The pickle key is a string of unspecified length and format. For AES, we need a 256-bit Uint8Array. - * So we HKDF the pickle key to generate the AES key. The AES key should be zeroed after it is used. - */ -async function pickleKeyToAesKey(pickleKey: string) { - const pickleKeyBuffer = new Uint8Array(pickleKey.length); - for (let i = 0; i < pickleKey.length; i++) { - pickleKeyBuffer[i] = pickleKey.charCodeAt(i); - } - const hkdfKey = await crypto.subtle.importKey( - "raw", - pickleKeyBuffer, - "HKDF", - false, - ["deriveBits"], - ); - pickleKeyBuffer.fill(0); - return new Uint8Array( - await crypto.subtle.deriveBits( - { - name: "HKDF", - hash: "SHA-256", - salt: new Uint8Array(32), - info: new Uint8Array(0), - }, - hkdfKey, - 256, - ), - ); -} - -function isEncryptedPayload( - token?: IEncryptedPayload | string, -): token is IEncryptedPayload { - return !!token && typeof token !== "string"; -} - -/** - * Try to decrypt a token retrieved from storage - * Where token is not encrypted (plain text) returns the plain text token - * Where token is encrypted, attempts decryption. Returns successfully decrypted token, else undefined. - */ -async function tryDecryptToken( - pickleKey: string | undefined, - token: IEncryptedPayload | string | undefined, - tokenIv: string, -): Promise<string | undefined> { - if (pickleKey && isEncryptedPayload(token)) { - const aesKey = await pickleKeyToAesKey(pickleKey); - const decryptedToken = await decryptAES(token, aesKey, tokenIv); - aesKey.fill(0); - return decryptedToken; - } - // if the token wasn't encrypted (plain string) just return it back - if (typeof token === "string") { - return token; - } - // otherwise return undefined } export function updateLocalStorageDeviceId(deviceId: string) { @@ -235,3 +56,15 @@ export async function clearMatrixStores(): Promise<void> { }); await temporaryMatrixClient.clearStores(); } + +export async function checkIfDatabaseExists(dbName: string) { + const databases = await getIDBFactory()?.databases(); + return Boolean(databases?.some((db) => db.name === dbName)); +} + +export async function checkIfLocalStorageDataExists() { + return ( + (await checkIfDatabaseExists("matrix-js-sdk::matrix-sdk-crypto")) && + isNonNullish(getCachedCredentials().deviceId) + ); +} diff --git a/employee-portal/src/lib/businessModules/chat/shared/ChatClientProvider.tsx b/employee-portal/src/lib/businessModules/chat/shared/ChatClientProvider.tsx index 735f9d9a66f2f0e71d7a9480d7a84101dc3d2422..b196e81d002a474b362b58994617a9c3345b63e1 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/ChatClientProvider.tsx +++ b/employee-portal/src/lib/businessModules/chat/shared/ChatClientProvider.tsx @@ -12,7 +12,6 @@ import { MatrixEvent, Room, RoomEvent, - SetPresence, createClient, } from "matrix-js-sdk"; import { KnownMembership, Membership } from "matrix-js-sdk/lib/types"; @@ -30,10 +29,10 @@ import { isNullish } from "remeda"; import { useGetDepartment } from "@/lib/businessModules/chat/api/queries/department"; import { useMessageTeaser } from "@/lib/businessModules/chat/components/messageTeaser/MessageTeaserProvider"; -import { useChat } from "@/lib/businessModules/chat/shared/ChatProvider"; import { ClientState } from "@/lib/businessModules/chat/shared/enums"; import { logger } from "@/lib/businessModules/chat/shared/helpers"; import { useChatLifecycle } from "@/lib/businessModules/chat/shared/hooks/useChatLifecycle"; +import { useIdleTimerHook } from "@/lib/businessModules/chat/shared/hooks/useIdleTimerHook"; import { routes } from "@/lib/businessModules/chat/shared/routes"; import { RoomEventDetails, @@ -50,6 +49,7 @@ export interface ChatClientContextType { clientState: ClientState; setClientState: Dispatch<SetStateAction<ClientState>>; departmentInfo?: ApiGetDepartmentInfoResponse; + isClientPrepared: boolean; } export const ChatClientContext = createContext<ChatClientContextType | null>( @@ -58,35 +58,22 @@ export const ChatClientContext = createContext<ChatClientContextType | null>( export function ChatClientProvider({ children }: Readonly<RequiresChildren>) { const showMessageTeaser = useMessageTeaser(); - const { configuration, userSettings } = useChat(); - const baseUrl = configuration.PUBLIC_MATRIX_SERVER_URL; - - const matrixClient = useRef<MatrixClient>(createClient({ baseUrl })); + const placeholderMatrixClient = createClient({ + baseUrl: "", + }); + const matrixClient = useRef(placeholderMatrixClient); const [clientState, setClientState] = useState<ClientState>(ClientState.Idle); const { data: departmentInfo } = useGetDepartment(); - // CHAT INIT - useChatLifecycle(matrixClient, clientState, setClientState); + const isClientPrepared = clientState === ClientState.Prepared; - useEffect(() => { - void (async () => { - if (!matrixClient) return; - if (clientState !== ClientState.Prepared) return; - - if (!userSettings.sharePresence) { - await matrixClient.current.setSyncPresence(SetPresence.Offline); - await matrixClient.current.setPresence({ presence: "offline" }); - } else { - await matrixClient.current.setSyncPresence(SetPresence.Online); - await matrixClient.current.setPresence({ presence: "online" }); - } - })(); - }, [clientState, matrixClient, userSettings.sharePresence]); + useIdleTimerHook(matrixClient, setClientState); + useChatLifecycle(matrixClient, clientState, setClientState); // Handle chat message teaser useEffect(() => { - if (clientState !== ClientState.Prepared) return; + if (!isClientPrepared) return; const currentMatrixClient = matrixClient.current; async function onMessage({ @@ -142,7 +129,7 @@ export function ChatClientProvider({ children }: Readonly<RequiresChildren>) { return () => { currentMatrixClient.removeListener(RoomEvent.Timeline, onRoomTimeline); }; - }, [clientState, showMessageTeaser]); + }, [isClientPrepared, showMessageTeaser]); /** * It notifies the user when they're not on the chat page @@ -168,7 +155,7 @@ export function ChatClientProvider({ children }: Readonly<RequiresChildren>) { * Automatically join rooms when invited */ useEffect(() => { - if (clientState !== ClientState.Prepared) return; + if (!isClientPrepared) return; const currentMatrixClient = matrixClient.current; function onMyMembership(room: Room, membership: Membership) { @@ -187,7 +174,7 @@ export function ChatClientProvider({ children }: Readonly<RequiresChildren>) { onMyMembership, ); }; - }, [clientState]); + }, [isClientPrepared]); const contextValues = useMemo<ChatClientContextType>( () => ({ @@ -195,8 +182,9 @@ export function ChatClientProvider({ children }: Readonly<RequiresChildren>) { setClientState, matrixClient: matrixClient.current, departmentInfo, + isClientPrepared, }), - [clientState, departmentInfo], + [clientState, departmentInfo, isClientPrepared], ); return ( diff --git a/employee-portal/src/lib/businessModules/chat/shared/ChatProvider.tsx b/employee-portal/src/lib/businessModules/chat/shared/ChatProvider.tsx index a5b86ee1289f84ecfa01c394fb2f2d6d7cb97339..2bef238afe5b662b4e1462d5612abe4cc246832b 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/ChatProvider.tsx +++ b/employee-portal/src/lib/businessModules/chat/shared/ChatProvider.tsx @@ -8,7 +8,7 @@ import { ApiUserRole } from "@eshg/base-api"; import { ApiChatFeature } from "@eshg/chat-management-api"; import { RequiresChildren } from "@eshg/lib-portal/types/react"; -import { createContext, useContext, useMemo } from "react"; +import { createContext, useContext, useEffect, useMemo } from "react"; import { doNothing, isNullish, omit } from "remeda"; import { useGetSelfUser } from "@/lib/baseModule/api/queries/users"; @@ -16,6 +16,7 @@ import { useMessagesSidebar } from "@/lib/baseModule/components/layout/messagesS import { useIsNewFeatureEnabledUnsuspended } from "@/lib/businessModules/chat/api/queries/featureTogglesApi"; import { useGetUserSettings } from "@/lib/businessModules/chat/api/queries/userSettingsApi"; import { MessageTeaserProvider } from "@/lib/businessModules/chat/components/messageTeaser/MessageTeaserProvider"; +import { validateCachedCredentials } from "@/lib/businessModules/chat/matrix/login"; import { ChatClientProvider } from "@/lib/businessModules/chat/shared/ChatClientProvider"; import { NotificationProvider } from "@/lib/businessModules/chat/shared/NotificationProvider"; import { ChatConfiguration } from "@/lib/businessModules/chat/shared/config"; @@ -68,6 +69,14 @@ function InnerChatProvider({ children, configuration }: ChatProviderProps) { canAccessChat, ); + useEffect(() => { + if (!selfUser) return; + void validateCachedCredentials( + selfUser.externalChatUsername, + /* initialValidation */ true, + ); + }, [selfUser]); + // Chat user settings const userSettings = useMemo<ChatUserSettings>( () => ({ @@ -77,6 +86,7 @@ function InnerChatProvider({ children, configuration }: ChatProviderProps) { sharePresence: false, showReadConfirmation: false, showTypingNotification: false, + accountRegistered: false, ...(userSettingsData && omit(userSettingsData, ["userId"])), }), [userSettingsData], @@ -129,6 +139,7 @@ function InnerChatProviderMock({ children, configuration }: ChatProviderProps) { sharePresence: false, showReadConfirmation: false, showTypingNotification: false, + accountRegistered: false, }, canAccessChat: false, isSettingsLoading: false, diff --git a/employee-portal/src/lib/businessModules/chat/shared/NotificationProvider.tsx b/employee-portal/src/lib/businessModules/chat/shared/NotificationProvider.tsx index 626fec21b61bf86ccbf39f759258c781d13cbeae..162bdacd47aeb3e3c3cb0d759cda822e4b85ee83 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/NotificationProvider.tsx +++ b/employee-portal/src/lib/businessModules/chat/shared/NotificationProvider.tsx @@ -10,7 +10,6 @@ import { createContext, useContext, useEffect, useState } from "react"; import { isNullish, omit } from "remeda"; import { useChatClientContext } from "@/lib/businessModules/chat/shared/ChatClientProvider"; -import { ClientState } from "@/lib/businessModules/chat/shared/enums"; export interface NotificationContextType { unreadNotificationsPerRoom: Record<string, number>; @@ -20,14 +19,14 @@ export const NotificationContext = createContext<NotificationContextType | null>(null); export function NotificationProvider({ children }: RequiresChildren) { - const { matrixClient, clientState } = useChatClientContext(); + const { matrixClient, isClientPrepared } = useChatClientContext(); const [unreadNotificationsPerRoom, setUnreadNotificationsPerRoom] = useState< Record<string, number> >({}); // Initial check for unread messages useEffect(() => { - if (clientState !== ClientState.Prepared) return; + if (!isClientPrepared) return; const rooms = matrixClient.getRooms(); const joinedRooms = rooms.filter( @@ -45,11 +44,11 @@ export function NotificationProvider({ children }: RequiresChildren) { ); setUnreadNotificationsPerRoom(initialNotifications); - }, [clientState, matrixClient]); + }, [isClientPrepared, matrixClient]); // Setting listeners for unread messages useEffect(() => { - if (clientState !== ClientState.Prepared) return; + if (!isClientPrepared) return; function setUnreadNotification(event: MatrixEvent, room?: Room | Error) { let eventRoom = room instanceof Error ? undefined : room; @@ -88,7 +87,7 @@ export function NotificationProvider({ children }: RequiresChildren) { setUnreadNotification, ); }; - }, [clientState, matrixClient]); + }, [isClientPrepared, matrixClient]); return ( <NotificationContext.Provider value={{ unreadNotificationsPerRoom }}> diff --git a/employee-portal/src/lib/businessModules/chat/shared/PresenceProvider.tsx b/employee-portal/src/lib/businessModules/chat/shared/PresenceProvider.tsx index 8c579ddb837a61396e8db573e727f340fd796196..c43965182772077b1c73e798514004d6ca5e50d7 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/PresenceProvider.tsx +++ b/employee-portal/src/lib/businessModules/chat/shared/PresenceProvider.tsx @@ -10,7 +10,6 @@ import { isNullish } from "remeda"; import { useChatClientContext } from "@/lib/businessModules/chat/shared/ChatClientProvider"; import { useChat } from "@/lib/businessModules/chat/shared/ChatProvider"; -import { ClientState } from "@/lib/businessModules/chat/shared/enums"; import { Presence, UsersPresence, @@ -23,25 +22,24 @@ export interface PresenceContextType { export const PresenceContext = createContext<PresenceContextType | null>(null); export function PresenceProvider({ children }: Readonly<RequiresChildren>) { - const { matrixClient, clientState } = useChatClientContext(); + const { matrixClient, isClientPrepared } = useChatClientContext(); const { userSettings: { sharePresence }, } = useChat(); const [usersPresence, setUsersPresence] = useState<UsersPresence>({}); useEffect(() => { - if (!matrixClient) return; - if (clientState !== ClientState.Prepared) return; - if (!sharePresence) return; + if (!matrixClient || !isClientPrepared || !sharePresence) return; + const users = matrixClient.getUsers(); const statuses = Object.fromEntries( users.map((user) => [user.userId, user.presence]), ) as UsersPresence; setUsersPresence(statuses); - }, [clientState, matrixClient, sharePresence]); + }, [isClientPrepared, matrixClient, sharePresence]); useEffect(() => { - if (clientState !== ClientState.Prepared) return; + if (!isClientPrepared) return; function handleUserPresence(event?: MatrixEvent, user?: User) { const eventType = event?.getType(); @@ -80,7 +78,7 @@ export function PresenceProvider({ children }: Readonly<RequiresChildren>) { handleUserPresence, ); }; - }, [clientState, matrixClient, sharePresence, usersPresence]); + }, [isClientPrepared, matrixClient, sharePresence, usersPresence]); const contextValues = useMemo<PresenceContextType>( () => ({ diff --git a/employee-portal/src/lib/businessModules/chat/shared/enums.ts b/employee-portal/src/lib/businessModules/chat/shared/enums.ts index 4e0c02393b8ecf71ba4d96f5142548afef4c5519..cebb196189f4c9f857b857cf2ef082c0eb48c38c 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/enums.ts +++ b/employee-portal/src/lib/businessModules/chat/shared/enums.ts @@ -4,15 +4,16 @@ */ export enum ClientState { - Idle = "Idle", + Registration = "REGISTRATION", + Idle = "IDLE", Authorized = "AUTHORIZED", ClientCreated = "CLIENT_CREATED", ReadyForEncryption = "READY_FOR_ENCRYPTION", CreateBackupKey = "CREATE_BACKUP_KEY", RestoreBackupKey = "RESTORE_BACKUP_KEY", - BackupSetupComplete = "BACKUP_SETUP_COMPLETE", Prepared = "PREPARED", Restart = "RESTART", + Reset = "RESET", Error = "ERROR", } diff --git a/employee-portal/src/lib/businessModules/chat/shared/helpers.ts b/employee-portal/src/lib/businessModules/chat/shared/helpers.ts index d55a6d1e0188e914d6b3d19b73431de98ebd76ca..c2f0fa4c0b67a3507a3aedd3d753f08b55b6bb23 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/helpers.ts +++ b/employee-portal/src/lib/businessModules/chat/shared/helpers.ts @@ -3,15 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable no-restricted-properties */ -/* eslint-disable no-console */ +import { env } from "@/env/client"; +/* eslint-disable no-console */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ - /* eslint-disable @typescript-eslint/no-explicit-any */ export const logger = (() => { - const isDev = process.env.NODE_ENV !== "production"; + const isDev = env.NODE_ENV !== "production"; function print(type: string, ...messages: any[]) { if (isDev) { diff --git a/employee-portal/src/lib/businessModules/chat/shared/hooks/useBackupInfo.ts b/employee-portal/src/lib/businessModules/chat/shared/hooks/useBackupInfo.ts index 688875218c2cf179045a9f6d98c7525866d82a89..ae3e5b9583875530f72a066ef7a5d4ba2d487d16 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/hooks/useBackupInfo.ts +++ b/employee-portal/src/lib/businessModules/chat/shared/hooks/useBackupInfo.ts @@ -3,13 +3,16 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { CryptoEvent } from "matrix-js-sdk"; -import { BackupTrustInfo, KeyBackupInfo } from "matrix-js-sdk/lib/crypto-api"; +import { + BackupTrustInfo, + CryptoEvent, + KeyBackupInfo, +} from "matrix-js-sdk/lib/crypto-api"; import { useCallback, useEffect, useState } from "react"; import { getBackupKeyStatus } from "@/lib/businessModules/chat/matrix/crypto"; import { useChatClientContext } from "@/lib/businessModules/chat/shared/ChatClientProvider"; -import { ClientState } from "@/lib/businessModules/chat/shared/enums"; +import { logger } from "@/lib/businessModules/chat/shared/helpers"; type BKStatus = Awaited<ReturnType<typeof getBackupKeyStatus>>; @@ -23,7 +26,7 @@ type BackupStatus = Partial< >; export function useBackupInfo() { - const { clientState, matrixClient } = useChatClientContext(); + const { matrixClient, isClientPrepared } = useChatClientContext(); const [backupStatus, setBackupStatus] = useState<BackupStatus>(); const updateState = useCallback((data: BackupStatus) => { @@ -33,7 +36,10 @@ export function useBackupInfo() { const loadBackupStatus = useCallback(async () => { const backupKeyStatus = await getBackupKeyStatus(matrixClient); try { - const backupInfo = await matrixClient.getKeyBackupVersion(); + const crypto = matrixClient.getCrypto(); + if (!crypto) throw new Error("CryptoApi is undefined"); + + const backupInfo = await crypto.getKeyBackupInfo(); const backupTrustInfo = backupInfo ? await matrixClient.getCrypto()?.isKeyBackupTrusted(backupInfo) : undefined; @@ -48,12 +54,13 @@ export function useBackupInfo() { activeBackupVersion, ...backupKeyStatus, }); - } catch { + } catch (error) { updateState({ backupInfo: null, backupTrustInfo: undefined, activeBackupVersion: null, }); + logger.error(error); } }, [matrixClient, updateState]); @@ -65,7 +72,7 @@ export function useBackupInfo() { ); useEffect(() => { - if (clientState !== ClientState.Prepared) return; + if (!isClientPrepared) return; void loadBackupStatus(); @@ -87,7 +94,7 @@ export function useBackupInfo() { ); }; }, [ - clientState, + isClientPrepared, loadBackupStatus, matrixClient, onKeyBackupSessionsRemaining, diff --git a/employee-portal/src/lib/businessModules/chat/shared/hooks/useChatLifecycle.tsx b/employee-portal/src/lib/businessModules/chat/shared/hooks/useChatLifecycle.tsx index 950b39b4bbb299203b39b6d8446d70993864a06e..662b082d9ac8037d130e33680a399492c376c8b7 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/hooks/useChatLifecycle.tsx +++ b/employee-portal/src/lib/businessModules/chat/shared/hooks/useChatLifecycle.tsx @@ -16,7 +16,6 @@ import { useCallback, useEffect, useRef, - useState, } from "react"; import { useUpdateSelfUserChatUsername } from "@/lib/baseModule/api/mutations/users"; @@ -24,20 +23,27 @@ import { useGetSelfUser, useGetUserProfile, } from "@/lib/baseModule/api/queries/users"; +import { useBindKeycloakId } from "@/lib/businessModules/chat/api/mutations/userAccountApi"; +import { useCreateOrUpdateUserSettings } from "@/lib/businessModules/chat/api/mutations/userSettingsApi"; import { - fetchBackupInfo, - getRustCryptoStoreArgs, + createStorageKey, isDeviceVerified, } from "@/lib/businessModules/chat/matrix/crypto"; import { cacheSecretStorageKey, getSecretStorageKey, } from "@/lib/businessModules/chat/matrix/cryptoCallbacks"; -import { chatLogin } from "@/lib/businessModules/chat/matrix/login"; -import { restoreKeyBackupWithCache } from "@/lib/businessModules/chat/matrix/secretStorage"; import { + createTemporaryMatrixClient, + fetchFn, + getCredentials, + requestCredentials, +} from "@/lib/businessModules/chat/matrix/login"; +import { restoreKeyBackup } from "@/lib/businessModules/chat/matrix/secretStorage"; +import { + clearCachedCredentials, clearMatrixStores, - deleteCachedCredentials, + persistCredentials, } from "@/lib/businessModules/chat/matrix/tokens"; import { useChat } from "@/lib/businessModules/chat/shared/ChatProvider"; import { chatSearchParamNames } from "@/lib/businessModules/chat/shared/constants"; @@ -46,8 +52,8 @@ import { logger } from "@/lib/businessModules/chat/shared/helpers"; import { IStoredCredentials } from "@/lib/businessModules/chat/shared/types"; import { clearSearchParams, - delayed, - validateChatUsername, + fetchBackupInfoWithRetry, + waitUntilCryptoApiIsInitialized, } from "@/lib/businessModules/chat/shared/utils"; export function useChatLifecycle( @@ -58,126 +64,202 @@ export function useChatLifecycle( const { data: selfUser } = useGetSelfUser(); const { data: userData } = useGetUserProfile(selfUser.userId); const updateSelfUser = useUpdateSelfUserChatUsername(); + const { configuration, userSettings } = useChat(); - const { configuration } = useChat(); + const { mutateAsync: bindKeycloakId } = useBindKeycloakId(); + const { mutateAsync: registerAccount } = useCreateOrUpdateUserSettings(); const baseUrl = configuration.PUBLIC_MATRIX_SERVER_URL; + const credentialsRef = useRef<IStoredCredentials | null>(null); + const wasRegisterFlowStarted = useRef(false); + const wasRegisterFlowFinished = useRef(false); + const wasExternalChatUsernameUpdated = useRef(false); + const wasMatrixClientInitialized = useRef(false); + const wasRustCryptoInitialized = useRef(false); + + function resetClientStateFlags() { + wasRegisterFlowStarted.current = false; + wasExternalChatUsernameUpdated.current = false; + wasMatrixClientInitialized.current = false; + wasRustCryptoInitialized.current = false; + } - const [credentials, setCredentials] = useState<IStoredCredentials>(); - const wasAuthenticated = useRef(false); + /** + * Resets the chat client by stopping the matrix client, resetting client state flags, + * clearing cached credentials, and clearing the matrix stores. Finally, it sets the client state to `Idle`. + */ + const resetChat = useCallback(async () => { + logger.warn("RESETTING CHAT"); - const restartChat = useCallback(async () => { - logger.warn("RESTARTING CHAT"); + matrixClient.current.stopClient(); + resetClientStateFlags(); + clearCachedCredentials(); + await clearMatrixStores(); + setClientState(ClientState.Idle); + }, [setClientState, matrixClient]); - await deleteCachedCredentials(); - void clearMatrixStores(); + /** + * Restarts the chat client by resetting the client state flags and setting the client state to `Idle`. + * This function is typically used to perform a soft reset of the chat. + */ + const restartChat = useCallback(() => { + logger.warn("RESTARTING CHAT"); - wasAuthenticated.current = false; + resetClientStateFlags(); setClientState(ClientState.Idle); }, [setClientState]); /** - * Prepare the matrix client - * - * It creates a client and logs in using stored credentials or via SSO. - * It verifies the logged-in user and caches the credentials. + * First ever whoami request creates synapse user account. + * Then Chat management is called to create synapse user mapping with keycloak user id. + * This ensures proper behavior of requests that require User-Interactive Authentication (E2EE passphrase reset, account deactivation). */ - const initChat = useCallback(async () => { - if (wasAuthenticated.current) return; - // Change this flag to avoid double render - wasAuthenticated.current = true; - - logger.info("PREPARE MATRIX CLIENT"); + const registerChatUser = useCallback(async () => { + if (wasRegisterFlowStarted.current) return; + wasRegisterFlowStarted.current = true; + logger.info("Step 0/4: REGISTER NEW CHAT USER"); + + if (userSettings.accountRegistered) { + logger.info("Account already registered, skipping"); + wasMatrixClientInitialized.current = false; + return setClientState(ClientState.Idle); + } try { - const creds = await chatLogin(baseUrl, selfUser); + const temporaryMatrixClient = createTemporaryMatrixClient(baseUrl); + const credentials = await requestCredentials(temporaryMatrixClient); + persistCredentials(credentials); + + await bindKeycloakId({ matrixUserId: credentials.userId }); + await registerAccount({ + userId: selfUser.userId, + accountRegistered: true, + }); - if (creds) { - setCredentials(creds); - setClientState(ClientState.Authorized); - } + logger.info("Registered new chat user: ", credentials); + + wasRegisterFlowFinished.current = true; + setClientState(ClientState.Restart); } catch (error) { - logger.error("Error logging into matrix chat:", error); + logger.error("Failed to register chat user", error); setClientState(ClientState.Error); } - void clearSearchParams(chatSearchParamNames.loginToken); - }, [baseUrl, selfUser, setClientState]); + clearSearchParams(chatSearchParamNames.loginToken); + }, [ + userSettings.accountRegistered, + setClientState, + baseUrl, + bindKeycloakId, + registerAccount, + selfUser.userId, + ]); /** - * Start the matrix client - * - * It creates and starts a new client based on verified credentials with crypto callbacks, and initiates Rust encryption. + * Create matrix client based on verified credentials with crypto callbacks + * - Call whoami endpoint to check if user is authenticated + * - Cache deviceId and matrix userId + * - Verify logged-in user with cached matrix userId */ - const createChatClient = useCallback(async () => { - if (!credentials) return; + const initMatrixClient = useCallback(async () => { + if (wasMatrixClientInitialized.current) return; + wasMatrixClientInitialized.current = true; - const { accessToken, deviceId, userId, pickleKey } = credentials; + if (!userSettings.accountRegistered && !wasRegisterFlowFinished.current) { + logger.info( + "INIT MATRIX CLIENT: Account not yet registered, starting register flow", + ); + return setClientState(ClientState.Registration); + } - logger.info("CREATE MATRIX CLIENT"); + logger.info("Step 1/4: INIT MATRIX CLIENT"); - // New client for encryption - matrixClient.current = createClient({ - baseUrl, - deviceId, - accessToken, - userId, - cryptoCallbacks: { - getSecretStorageKey: (keys) => - getSecretStorageKey(keys, matrixClient.current), - cacheSecretStorageKey, - }, - }); + try { + const credentials = await getCredentials( + baseUrl, + selfUser.externalChatUsername, + ); - logger.info("Start matrix client as user:", userId); + logger.info("Setting credentialsRef: ", credentials); + credentialsRef.current = credentials; + + matrixClient.current = createClient({ + baseUrl: baseUrl, + deviceId: credentials.deviceId, + userId: credentials.userId, + fetchFn: (input, init) => fetchFn(input, init, credentials.deviceId), + cryptoCallbacks: { + getSecretStorageKey: (keys) => + getSecretStorageKey(keys, matrixClient.current), + cacheSecretStorageKey, + }, + }); - const rustCryptoStoreArgs = getRustCryptoStoreArgs(pickleKey); + setClientState(ClientState.Authorized); + } catch (error) { + logger.error("Error logging into matrix chat:", error); + setClientState(ClientState.Error); + } + logger.info("FINISHED Step 1/4: INIT MATRIX CLIENT"); + }, [ + baseUrl, + selfUser.externalChatUsername, + setClientState, + userSettings, + matrixClient, + ]); - logger.info("INIT RUST CRYPTO"); + /** + * Initiate matrix-sdk-crypto-wasm for E2EE communication and start matrixClient. + */ + const initRustCryptoAndStartMatrixClient = useCallback(async () => { + if (wasRustCryptoInitialized.current || !credentialsRef.current?.deviceId) + return; + wasRustCryptoInitialized.current = true; try { + logger.info("Step 2/4: INIT RUST CRYPTO"); + const storageKey = await createStorageKey( + selfUser.userId, + credentialsRef.current.deviceId, + ); await matrixClient.current.initRustCrypto({ - storageKey: rustCryptoStoreArgs.rustCryptoStoreKey, - storagePassword: rustCryptoStoreArgs.rustCryptoStorePassword, + storageKey, + }); + await waitUntilCryptoApiIsInitialized(matrixClient.current); + logger.info("FINISHED Step 2/4: INIT RUST CRYPTO"); + + //Changing the client's state to ClientCreated will initiate listening for sync events. + setClientState(ClientState.ClientCreated); + + logger.info("Step 3/4: START MATRIX CLIENT"); + await matrixClient.current.startClient({ + initialSyncLimit: 20, }); + logger.info("FINISHED Step 3/4: START MATRIX CLIENT"); } catch (error) { - logger.error("Init Rust crypto error", error); + logger.error("Error starting matrix client", error); setClientState(ClientState.Error); - return; } - - setClientState(ClientState.ClientCreated); - - logger.info("START MATRIX CLIENT"); - - await matrixClient.current.startClient({ - initialSyncLimit: 20, - includeArchivedRooms: true, - }); - }, [baseUrl, credentials, matrixClient, setClientState]); + }, [matrixClient, selfUser.userId, setClientState]); /** - * Handle matrix encryption + * Initialize E2EE key stores */ - const handleChatEncryption = useCallback(async () => { - logger.info("HANDLE CHAT ENCRYPTION"); + const initChatEncryption = useCallback(async () => { + logger.info("Step 4/4: INIT CHAT ENCRYPTION"); try { - let res = await fetchBackupInfo(matrixClient.current); + const backupInfo = await fetchBackupInfoWithRetry(matrixClient.current); - if (!res.has4S && res.backupInfo) { - res = await delayed(() => fetchBackupInfo(matrixClient.current), 300); - } - - if (!res.has4S || !res.backupInfo) { + if (!backupInfo?.has4SKey || !backupInfo?.keyBackupInfo) { setClientState(ClientState.CreateBackupKey); } else { - const restored = await restoreKeyBackupWithCache( + const isKeyBackupRestored = await restoreKeyBackup( matrixClient.current, - res.backupInfo, ); - const isVerified = await isDeviceVerified(matrixClient.current); + const isVerifiedDevice = await isDeviceVerified(matrixClient.current); - if (!restored || !isVerified) { + if (!isKeyBackupRestored || !isVerifiedDevice) { setClientState(ClientState.RestoreBackupKey); } else { setClientState(ClientState.Prepared); @@ -188,84 +270,97 @@ export function useChatLifecycle( matrixClient.current.stopClient(); setClientState(ClientState.Error); } + logger.info("FINISHED Step 4/4: INIT CHAT ENCRYPTION"); }, [matrixClient, setClientState]); const updateMatrixUserDisplayName = useCallback(async () => { - if (!matrixClient.current.isLoggedIn() || !credentials?.userId) return; + if (!credentialsRef.current?.userId) return; try { const profile = await matrixClient.current.getProfileInfo( - credentials?.userId, + credentialsRef.current.userId, + "displayname", ); - const selfUserDisplayName = selfUser.firstName + " " + selfUser.lastName; - if (selfUserDisplayName !== profile?.displayname) { - logger.info("Updating matrix user displayName: " + selfUserDisplayName); - await matrixClient.current.setDisplayName(selfUserDisplayName); + const matrixUserDisplayName = + selfUser.firstName + " " + selfUser.lastName; + if (matrixUserDisplayName !== profile?.displayname) { + logger.info("Updating matrixUserDisplayName: " + matrixUserDisplayName); + await matrixClient.current.setDisplayName(matrixUserDisplayName); } } catch (error) { logger.softError("Error updating matrix user displayName: ", error); } - }, [ - credentials?.userId, - matrixClient, - selfUser.firstName, - selfUser.lastName, - ]); + }, [matrixClient, selfUser.firstName, selfUser.lastName]); const updateSelfUserChatUsername = useCallback(async () => { - if (!matrixClient.current.isLoggedIn() || !credentials?.userId) return; - if (validateChatUsername(userData.user.externalChatUsername)) return; - - await updateSelfUser - .mutateAsync({ - externalChatUsername: credentials.userId, - phoneNumber: userData.user.phoneNumber, - salutation: userData.salutation, - title: userData.title, - }) - .catch((error) => { - logger.softError("Error updating self user: ", error); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [credentials?.userId, userData]); + if (!credentialsRef.current?.userId) return; + if (wasExternalChatUsernameUpdated.current) return; + + if (credentialsRef.current.userId !== userData.user.externalChatUsername) { + logger.info( + "Updating selfUser externalChatUsername: ", + credentialsRef.current.userId, + ); + + wasExternalChatUsernameUpdated.current = true; + await updateSelfUser + .mutateAsync({ + externalChatUsername: credentialsRef.current.userId, + phoneNumber: userData.user.phoneNumber, //TODO: provide new api endpoint to update only externalChatUsername + salutation: userData.salutation, + title: userData.title, + }) + .catch((error) => { + wasExternalChatUsernameUpdated.current = false; + logger.softError("Error updating selfUser's chat userId: ", error); + }); + } + }, [updateSelfUser, userData]); useEffect(() => { switch (clientState) { + case ClientState.Registration: + void registerChatUser(); + break; case ClientState.Idle: - void initChat(); + void initMatrixClient(); break; case ClientState.Authorized: - void createChatClient(); + void initRustCryptoAndStartMatrixClient(); break; case ClientState.ReadyForEncryption: void updateMatrixUserDisplayName(); void updateSelfUserChatUsername(); - void handleChatEncryption(); + void initChatEncryption(); break; case ClientState.Restart: void restartChat(); break; + case ClientState.Reset: + void resetChat(); + break; default: break; } }, [ clientState, - createChatClient, - handleChatEncryption, - initChat, - restartChat, + initRustCryptoAndStartMatrixClient, + initChatEncryption, + initMatrixClient, + resetChat, updateMatrixUserDisplayName, updateSelfUserChatUsername, + registerChatUser, + restartChat, ]); - const matrix = matrixClient.current; - useEffect(() => { if ( - clientState !== ClientState.ClientCreated && - clientState !== ClientState.Prepared + clientState === ClientState.Idle || + clientState === ClientState.Authorized ) return; + function handleSync(state: SyncState) { logger.debug("SyncState", state); switch (state) { @@ -279,12 +374,12 @@ export function useChatLifecycle( } } - matrix.on(ClientEvent.Sync, handleSync); + matrixClient.current.on(ClientEvent.Sync, handleSync); return () => { - matrix.off(ClientEvent.Sync, handleSync); + matrixClient.current.off(ClientEvent.Sync, handleSync); }; - }, [clientState, matrix, setClientState]); + }, [clientState, matrixClient, setClientState]); return null; } diff --git a/employee-portal/src/lib/businessModules/chat/shared/hooks/useChatRoomList.tsx b/employee-portal/src/lib/businessModules/chat/shared/hooks/useChatRoomList.tsx index ffb9034c8aa044ef7dc33a3d666d0d4e66d91ee5..5667c46af265049dfb7551e47274831fae1717b3 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/hooks/useChatRoomList.tsx +++ b/employee-portal/src/lib/businessModules/chat/shared/hooks/useChatRoomList.tsx @@ -17,7 +17,6 @@ import { useCallback, useEffect, useState } from "react"; import { useChatClientContext } from "@/lib/businessModules/chat/shared/ChatClientProvider"; import { - ClientState, CommunicationType, MessageTypeEnum, } from "@/lib/businessModules/chat/shared/enums"; @@ -34,7 +33,7 @@ import { } from "@/lib/businessModules/chat/shared/utils"; export function useChatRoomList() { - const { matrixClient, clientState } = useChatClientContext(); + const { matrixClient } = useChatClientContext(); const [roomList, setRoomList] = useState<RoomData[]>([]); const onMessage = useCallback( @@ -80,9 +79,6 @@ export function useChatRoomList() { useEffect(() => { void (async () => { - if (clientState !== ClientState.Prepared) { - return; - } await matrixClient.syncLeftRooms(); const rooms = matrixClient.getRooms(); const joinedRooms = rooms.filter( @@ -125,7 +121,7 @@ export function useChatRoomList() { ); setRoomList(roomWithTypeFiltered); })(); - }, [clientState, getLatestMessage, matrixClient]); + }, [getLatestMessage, matrixClient]); // Listening for my membership in chat rooms useEffect(() => { diff --git a/employee-portal/src/lib/businessModules/chat/shared/hooks/useCrossSigningInfo.ts b/employee-portal/src/lib/businessModules/chat/shared/hooks/useCrossSigningInfo.ts index f3e168165316d12ebf60e7f3389049dc0cef43cd..38dd3dde8e33d41b077476dd61a95f2fd0cd7110 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/hooks/useCrossSigningInfo.ts +++ b/employee-portal/src/lib/businessModules/chat/shared/hooks/useCrossSigningInfo.ts @@ -4,18 +4,18 @@ */ /* eslint-disable @typescript-eslint/no-misused-promises */ -import { ClientEvent, CryptoEvent, MatrixEvent } from "matrix-js-sdk"; +import { ClientEvent, MatrixEvent } from "matrix-js-sdk"; +import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api"; import { useCallback, useEffect, useState } from "react"; import { getCrossSigningStatus } from "@/lib/businessModules/chat/matrix/crypto"; import { useChatClientContext } from "@/lib/businessModules/chat/shared/ChatClientProvider"; -import { ClientState } from "@/lib/businessModules/chat/shared/enums"; type RTCrossSigningStatus = Awaited<ReturnType<typeof getCrossSigningStatus>>; type CsStatus = Partial<RTCrossSigningStatus>; export function useCrossSigningInfo() { - const { clientState, matrixClient } = useChatClientContext(); + const { matrixClient, isClientPrepared } = useChatClientContext(); const [crossSigningStatus, setCrossSigningStatus] = useState<CsStatus>(); const getUpdatedStatus = useCallback(async () => { @@ -37,7 +37,7 @@ export function useCrossSigningInfo() { ); useEffect(() => { - if (clientState !== ClientState.Prepared) return; + if (!isClientPrepared) return; matrixClient.on(ClientEvent.AccountData, onAccountData); matrixClient.on(CryptoEvent.UserTrustStatusChanged, getUpdatedStatus); @@ -49,7 +49,7 @@ export function useCrossSigningInfo() { matrixClient.off(CryptoEvent.UserTrustStatusChanged, getUpdatedStatus); matrixClient.off(CryptoEvent.KeysChanged, getUpdatedStatus); }; - }, [clientState, getUpdatedStatus, matrixClient, onAccountData]); + }, [getUpdatedStatus, isClientPrepared, matrixClient, onAccountData]); return { crossSigningStatus, loadCrossSigningStatus: getUpdatedStatus }; } diff --git a/employee-portal/src/lib/businessModules/chat/shared/hooks/useGetSelfUserPresence.tsx b/employee-portal/src/lib/businessModules/chat/shared/hooks/useGetSelfUserPresence.tsx index 45e8a72e6df2a64a0ce35b592c9b0a1364891e02..9051bb70e5227801de69619f43b5212cc612c14b 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/hooks/useGetSelfUserPresence.tsx +++ b/employee-portal/src/lib/businessModules/chat/shared/hooks/useGetSelfUserPresence.tsx @@ -26,14 +26,12 @@ export function useGetSelfUserPresence() { let userPresence: Presence | undefined = undefined; const sharePresence = userSettings.sharePresence; - if (isChatEnabled) { - if (userSettings.sharePresence) { - userPresence = usersPresence[loggedInUserId ?? ""]; - } + if (isChatEnabled && userSettings.sharePresence) { + userPresence = usersPresence[loggedInUserId ?? ""]; } return { userPresence, - sharePresence: sharePresence && isChatEnabled, + sharePresence: Boolean(sharePresence && isChatEnabled), }; }, [ isChatEnabled, diff --git a/employee-portal/src/lib/businessModules/chat/shared/hooks/useIdleTimerHook.tsx b/employee-portal/src/lib/businessModules/chat/shared/hooks/useIdleTimerHook.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b9adb57ba9492b7c1fb70d5360c953121a51ada3 --- /dev/null +++ b/employee-portal/src/lib/businessModules/chat/shared/hooks/useIdleTimerHook.tsx @@ -0,0 +1,33 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { MatrixClient } from "matrix-js-sdk"; +import { Dispatch, MutableRefObject, SetStateAction } from "react"; +import { IIdleTimerProps, useIdleTimer } from "react-idle-timer"; + +import { ClientState } from "@/lib/businessModules/chat/shared/enums"; +import { logger } from "@/lib/businessModules/chat/shared/helpers"; +import { setPresenceOffline } from "@/lib/businessModules/chat/shared/utils"; + +export function useIdleTimerHook( + matrixClient: MutableRefObject<MatrixClient>, + setClientState: Dispatch<SetStateAction<ClientState>>, + idleTimerProps?: IIdleTimerProps, +) { + useIdleTimer({ + onIdle() { + logger.info("Chat onIdle"); + void setPresenceOffline(matrixClient.current).then(() => { + matrixClient.current.stopClient(); + }); + }, + onActive() { + logger.info("Chat onActive"); + setClientState(ClientState.Restart); + }, + timeout: 300000, + ...idleTimerProps, + }); +} diff --git a/employee-portal/src/lib/businessModules/chat/shared/hooks/usePresence.tsx b/employee-portal/src/lib/businessModules/chat/shared/hooks/usePresence.tsx index ac43b0da88985857515fc24b17d144f95f95f9b4..14ffbc7d18eb0459dbfd7c6c2dbdc46a24c28574 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/hooks/usePresence.tsx +++ b/employee-portal/src/lib/businessModules/chat/shared/hooks/usePresence.tsx @@ -3,12 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ClientEvent, MatrixEvent } from "matrix-js-sdk"; +import { ClientEvent, MatrixEvent, SyncState } from "matrix-js-sdk"; import { useContext, useEffect, useState } from "react"; +import { omit } from "remeda"; import { ChatClientContext } from "@/lib/businessModules/chat/shared/ChatClientProvider"; import { useChat } from "@/lib/businessModules/chat/shared/ChatProvider"; -import { ClientState } from "@/lib/businessModules/chat/shared/enums"; import { Presence, UsersPresence, @@ -19,14 +19,13 @@ export function usePresence(userId?: string) { userSettings: { sharePresence, accountDeactivated }, } = useChat(); const chatContext = useContext(ChatClientContext); - const { matrixClient, clientState } = chatContext ?? {}; + const { matrixClient, isClientPrepared } = chatContext ?? {}; const [usersPresence, setUsersPresence] = useState<UsersPresence>({}); // Get initial users presence useEffect(() => { - if (!matrixClient) return; - if (clientState !== ClientState.Prepared) return; - if (accountDeactivated) return; + if (!matrixClient || !isClientPrepared || accountDeactivated) return; + if (userId) { const user = matrixClient.getUser(userId); setUsersPresence({ [userId]: user?.presence } as UsersPresence); @@ -37,11 +36,10 @@ export function usePresence(userId?: string) { ) as UsersPresence; setUsersPresence(statuses); } - }, [accountDeactivated, clientState, matrixClient, userId]); + }, [accountDeactivated, isClientPrepared, matrixClient, userId]); useEffect(() => { - if (clientState !== ClientState.Prepared) return; - if (accountDeactivated) return; + if (!isClientPrepared || accountDeactivated) return; function handleUserPresence(event: MatrixEvent) { const eventType = event.getType(); @@ -71,11 +69,28 @@ export function usePresence(userId?: string) { }; }, [ accountDeactivated, - clientState, + isClientPrepared, matrixClient, sharePresence, userId, usersPresence, ]); + + useEffect(() => { + if (!isClientPrepared || !matrixClient) return; + + function handleStoppedSync(state: SyncState) { + if (state === SyncState.Stopped && userId) { + setUsersPresence((prevState) => omit(prevState, [userId])); + } + } + + matrixClient.on(ClientEvent.Sync, handleStoppedSync); + + return () => { + matrixClient.off(ClientEvent.Sync, handleStoppedSync); + }; + }, [isClientPrepared, matrixClient, userId]); + return { usersPresence: sharePresence ? usersPresence : {} }; } diff --git a/employee-portal/src/lib/businessModules/chat/shared/hooks/useRoomTimeline.tsx b/employee-portal/src/lib/businessModules/chat/shared/hooks/useRoomTimeline.tsx index edecef83ee3f7b99d9a7deb16d58087b18c6369a..6db182f9d336a48a9b309b27e8d81715331e5097 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/hooks/useRoomTimeline.tsx +++ b/employee-portal/src/lib/businessModules/chat/shared/hooks/useRoomTimeline.tsx @@ -20,7 +20,6 @@ import { validate as isUUID, v4 as uuidv4 } from "uuid"; import { useMessageTeaser } from "@/lib/businessModules/chat/components/messageTeaser/MessageTeaserProvider"; import { useChatClientContext } from "@/lib/businessModules/chat/shared/ChatClientProvider"; import { - ClientState, Membership, MessageTypeEnum, } from "@/lib/businessModules/chat/shared/enums"; @@ -43,7 +42,7 @@ const messagesLimit = 20; export function useRoomTimeline(roomId: string) { const [messages, setMessages] = useState<(Message | ChatSystemMessage)[]>([]); const [hasNextPage, setHasNextPage] = useState<boolean>(true); - const { matrixClient, clientState } = useChatClientContext(); + const { matrixClient } = useChatClientContext(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(false); const currentRoom = matrixClient.getRoom(roomId); @@ -451,12 +450,11 @@ export function useRoomTimeline(roomId: string) { useEffect(() => { void (async () => { - if (clientState !== ClientState.Prepared) return; if (hasInitialData.current) return; hasInitialData.current = true; await fetchRoomMessages(); })(); - }, [clientState, fetchRoomMessages]); + }, [fetchRoomMessages]); return { fetchRoomMessages, diff --git a/employee-portal/src/lib/businessModules/chat/shared/sideNavigationItem.tsx b/employee-portal/src/lib/businessModules/chat/shared/sideNavigationItem.tsx index 03a74dfd9165cb56d57304b244bd9d4b2949e583..aec5a429d39b81c18f779add24941f4170323755 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/sideNavigationItem.tsx +++ b/employee-portal/src/lib/businessModules/chat/shared/sideNavigationItem.tsx @@ -5,12 +5,12 @@ import { ApiUserRole } from "@eshg/base-api"; import { hasUserRole } from "@eshg/lib-employee-portal/helpers/accessControl"; -import { ChatOutlined } from "@mui/icons-material"; - import { SideNavigationItem, UseSideNavigationItemsResult, -} from "@/lib/baseModule/components/layout/sideNavigation/types"; +} from "@eshg/lib-employee-portal/types/sideNavigation"; +import { ChatOutlined } from "@mui/icons-material"; + import { ChatMessageCounter } from "@/lib/businessModules/chat/components/ChatMessageCounter"; import { useChat } from "@/lib/businessModules/chat/shared/ChatProvider"; diff --git a/employee-portal/src/lib/businessModules/chat/shared/types.ts b/employee-portal/src/lib/businessModules/chat/shared/types.ts index a78332d77cc604403d6b2e94cc0d1960815c0f7c..7404a92a534aa836d9c6984382f6e7da285004bb 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/types.ts +++ b/employee-portal/src/lib/businessModules/chat/shared/types.ts @@ -121,13 +121,12 @@ export interface ChatUserSettings { sharePresence: boolean; showReadConfirmation: boolean; showTypingNotification: boolean; + accountRegistered: boolean; } export interface IStoredCredentials { - accessToken: string; - userId: string; - deviceId: string; - pickleKey: string | null; + userId?: string; + deviceId?: string; } export interface RoomLastMessage { diff --git a/employee-portal/src/lib/businessModules/chat/shared/utils.ts b/employee-portal/src/lib/businessModules/chat/shared/utils.ts index 881f031300529c53f148e6d7282532fcbb8045d2..673e468c560f80f9a5f4bf9af94931c81e1dc56f 100644 --- a/employee-portal/src/lib/businessModules/chat/shared/utils.ts +++ b/employee-portal/src/lib/businessModules/chat/shared/utils.ts @@ -23,13 +23,12 @@ import { ReceiptType, Room, RoomMember, + SetPresence, User, } from "matrix-js-sdk"; import { filter, - forEach, isEmpty, - isNonNullish, isStrictEqual, isString, keys, @@ -37,7 +36,9 @@ import { pipe, } from "remeda"; +import { fetchBackupInfo } from "@/lib/businessModules/chat/matrix/crypto"; import { CommunicationType } from "@/lib/businessModules/chat/shared/enums"; +import { logger } from "@/lib/businessModules/chat/shared/helpers"; import { ChatSystemMessage, Message, @@ -258,7 +259,7 @@ export function getStatusColor(status: Presence | undefined) { } } -export function getPresenseLabel(status: Presence | undefined) { +export function getPresenceLabel(status: Presence | undefined) { switch (status) { case "online": return "Online"; @@ -331,6 +332,103 @@ export function delayed<T>(fn: () => T, delay: number): Promise<T> { }); } +export async function waitUntilCryptoApiIsInitialized( + matrixClient: MatrixClient, +) { + logger.info("Waiting crypto initialization to complete..."); + const cryptoApi = await retryOperation( + () => matrixClient.getCrypto(), + (cryptoApi) => cryptoApi !== undefined, + 5, + 1000, + ); + if (!cryptoApi) { + throw Error( + "Rust Crypto initialization failed: Crypto module not available.", + ); + } + logger.info("Waiting crypto initialization to complete... - DONE"); +} + +export async function fetchBackupInfoWithRetry(matrixClient: MatrixClient) { + logger.info("Fetching backup info..."); + const backupInfo = await retryAsyncOperation( + async () => await fetchBackupInfo(matrixClient), + (backupInfo) => + !backupInfo.has4SKey && backupInfo.keyBackupInfo ? false : true, + 3, + 3000, + ); + logger.info("Fetching backup info... - DONE"); + return backupInfo; +} + +export async function retryOperation<T>( + operation: () => T, // The async function to retry + stopCondition: (result: T) => boolean, // A condition to stop retrying + retries: number, // Maximum number of retries + delay: number, // Delay in ms between retries + failOnLastRetry = false, // Throw an error if retry reached its limit +): Promise<T | undefined> { + let result: T | undefined = undefined; + for (let attempt = 0; attempt < retries; attempt++) { + try { + result = operation(); + if (stopCondition(result)) { + return result; + } + logger.info("Retrying operation... "); + } catch (error) { + if (attempt === retries - 1) { + throw error; // If it's the last retry, throw the error + } + logger.error("Retrying on operation error", error); + } + + // Wait before the next retry + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + if (failOnLastRetry) { + throw new Error(`Operation failed after ${retries} retries`); + } else { + return result; + } +} + +export async function retryAsyncOperation<T>( + operation: () => Promise<T>, // The async function to retry + stopCondition: (result: T) => boolean, // A condition to stop retrying + retries: number, // Maximum number of retries + delay: number, // Delay in ms between retries + failOnLastRetry = false, // Throw an error if retry reached its limit +): Promise<T | undefined> { + let result: T | undefined = undefined; + for (let attempt = 0; attempt < retries; attempt++) { + try { + result = await operation(); + if (stopCondition(result)) { + return result; + } + logger.info("Retrying operation... "); + } catch (error) { + if (attempt === retries - 1) { + throw error; // If it's the last retry, throw the error + } + logger.error("Retrying on operation error", error); + } + + // Wait before the next retry + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + if (failOnLastRetry) { + throw new Error(`Operation failed after ${retries} retries`); + } else { + return result; + } +} + function getImageUrl(matrixClient: MatrixClient, url: string | null) { if (!url) return null; @@ -472,13 +570,10 @@ export function getReadReceipts( export function clearSearchParams(...paramNames: string[]) { const url = new URL(window.location.href); - forEach(paramNames, (paramName) => { - const searchParam = url.searchParams.get(paramName); - if (isNonNullish(searchParam)) { - url.searchParams.delete(paramName); - } + paramNames.forEach((paramName) => { + url.searchParams.delete(paramName); }); - window.history.replaceState(null, "", url.href); + window.history.replaceState(null, "", url.toString()); } export function getRoomAdmins(room: Room | null) { @@ -589,3 +684,21 @@ export function isMembershipChanged(mEvent: MatrixEvent): boolean { mEvent.getContent().reason !== mEvent.getPrevContent().reason ); } + +export async function setPresenceOffline(matrixClient: MatrixClient) { + try { + await matrixClient.setSyncPresence(SetPresence.Offline); + await matrixClient.setPresence({ presence: SetPresence.Offline }); + } catch (error) { + logger.error("Failed to set user presence to offline", error); + } +} + +export async function setPresenceOnline(matrixClient: MatrixClient) { + try { + await matrixClient.setSyncPresence(SetPresence.Online); + await matrixClient.setPresence({ presence: SetPresence.Online }); + } catch (error) { + logger.error("Failed to set user presence to online", error); + } +} diff --git a/employee-portal/src/lib/businessModules/dental/features/children/details/ChildDetails.tsx b/employee-portal/src/lib/businessModules/dental/features/children/details/ChildDetails.tsx index f95c7d0e3e33d8db59fbf085892e5eb39482cdf4..dd572b05692741c233d4cb67b8f5f0e0b42ba4c7 100644 --- a/employee-portal/src/lib/businessModules/dental/features/children/details/ChildDetails.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/children/details/ChildDetails.tsx @@ -5,19 +5,16 @@ import { ChildDetails } from "@eshg/dental/api/models/ChildDetails"; import { useIsFormDisabled } from "@eshg/lib-portal/components/form/DisabledFormContext"; -import { formatDate } from "@eshg/lib-portal/formatters/dateTime"; -import { Divider, Grid, Stack, Typography } from "@mui/joy"; +import { Divider, Grid, Stack } from "@mui/joy"; import { AnnualInstitutionsTable } from "@/lib/businessModules/dental/features/children/details/AnnualInstitutionsTable"; -import { FluoridationConsentTable } from "@/lib/businessModules/dental/features/children/details/FluoridationConsentTable"; import { useUpdateAnnualChildSidebar } from "@/lib/businessModules/dental/features/children/details/UpdateAnnualChildSidebar"; -import { IconTooltipButton } from "@/lib/shared/components/buttons/IconTooltipButton"; +import { FluoridationConsentInformationSection } from "@/lib/businessModules/dental/shared/FluoridationConsentInformationSection"; import { CentralFilePersonDetails } from "@/lib/shared/components/centralFile/display/CentralFilePersonDetails"; import { ContentPanel } from "@/lib/shared/components/contentPanel/ContentPanel"; import { DetailsSection } from "@/lib/shared/components/detailsSection/DetailsSection"; import { DetailsItem } from "@/lib/shared/components/detailsSection/items/DetailsItem"; import { PageGrid } from "@/lib/shared/components/page/PageGrid"; -import { displayBoolean } from "@/lib/shared/helpers/booleans"; const SPACING = { xxs: 2, sm: 3, md: 4, xxl: 5 }; @@ -60,48 +57,10 @@ export function ChildDetailsPage(props: ChildDetailsProps) { <Stack gap={1}> <DetailsItem label="Einrichtung" value={child.institution.name} /> <DetailsItem label="Gruppe" value={child.groupName} /> - {child.currentFluoridationConsent ? ( - <> - <Divider /> - <Typography> - Einverständnis zur Fluoridierung{" "} - <IconTooltipButton - title="Übersicht Einverständnis zur Fluoridierung" - infoText={ - <FluoridationConsentTable - fluoridationConsent={child.allFluoridationConsents} - /> - } - icon="(Übersicht)" - /> - </Typography> - <Stack direction="row" gap={2} flexWrap="wrap"> - <DetailsItem - label="Einverständis" - value={displayBoolean( - child.currentFluoridationConsent.consented, - )} - /> - <DetailsItem - label="Datum der Einverständniserklärung" - value={formatDate( - child.currentFluoridationConsent.dateOfConsent, - )} - /> - <DetailsItem - label="Allergie" - value={displayBoolean( - child.currentFluoridationConsent.hasAllergy, - )} - /> - </Stack> - </> - ) : ( - <DetailsItem - label="Einverständis zur Fluoridierung" - value="Liegt nicht vor" - /> - )} + <Divider orientation="horizontal" /> + <FluoridationConsentInformationSection + allFluoridationConsents={child.allFluoridationConsents} + /> </Stack> </DetailsSection> </ContentPanel> diff --git a/employee-portal/src/lib/businessModules/dental/features/children/details/ChildExaminationForm.tsx b/employee-portal/src/lib/businessModules/dental/features/children/details/ChildExaminationForm.tsx index 2afecf48329ef1ebfa5f961285fa3ea3e43aee5e..18ca9b0848070ffe8cf8887160a0ed3be418eaac 100644 --- a/employee-portal/src/lib/businessModules/dental/features/children/details/ChildExaminationForm.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/children/details/ChildExaminationForm.tsx @@ -6,6 +6,7 @@ "use client"; import { + ApiDentitionType, ApiExaminationResult, UpdateExaminationRequest, } from "@eshg/dental-api"; @@ -100,6 +101,7 @@ function mapExaminationResultRequest( oralHygieneStatus: mapOptionalValue(formValues.oralHygieneStatus), fluorideVarnishApplied: mapOptionalValue(formValues.fluorideVarnishApplied) ?? false, + dentitionType: ApiDentitionType.Mixed, toothDiagnoses: Object.values(toothDiagnoses), }; } diff --git a/employee-portal/src/lib/businessModules/dental/features/children/details/UpdateAnnualChildSidebar.tsx b/employee-portal/src/lib/businessModules/dental/features/children/details/UpdateAnnualChildSidebar.tsx index ae6871de827dde1ee32f537edca2319b9cee5b7c..ad3f014ccd58b4d9ce51fb934e44552888838698 100644 --- a/employee-portal/src/lib/businessModules/dental/features/children/details/UpdateAnnualChildSidebar.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/children/details/UpdateAnnualChildSidebar.tsx @@ -155,7 +155,7 @@ function UpdateAnnualChildSidebar(props: UpdateAnnualChildSidebarProps) { <Stack direction="row" gap={2} flexWrap="wrap"> <BooleanSelectField name="fluoridationConsent.consented" - label="Einverständnis gegeben" + label="Einverständnis" required={ isDefined(values.fluoridationConsent?.dateOfConsent) && !isEmptyString(values.fluoridationConsent.dateOfConsent) @@ -166,7 +166,7 @@ function UpdateAnnualChildSidebar(props: UpdateAnnualChildSidebarProps) { /> <DateField name="fluoridationConsent.dateOfConsent" - label="Datum der Einverständniserklärung" + label="Datum" validate={(value) => isDefined(value) ? validatePastOrTodayDate(value) diff --git a/employee-portal/src/lib/businessModules/dental/features/children/new/CreateChildSidebar.tsx b/employee-portal/src/lib/businessModules/dental/features/children/new/CreateChildSidebar.tsx index fec50204e135f498b70affde5250c2b40f7743fc..1d1797cd5da7d08bff4c1465214037fc660aba4f 100644 --- a/employee-portal/src/lib/businessModules/dental/features/children/new/CreateChildSidebar.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/children/new/CreateChildSidebar.tsx @@ -7,6 +7,7 @@ import { ApiAddContact200Response } from "@eshg/base-api"; import { ApiCreateChildRequest } from "@eshg/dental-api"; +import { ApiChild } from "@eshg/dental-api"; import { useCreateChild } from "@eshg/dental/api/mutations/childApi"; import { getChildrenByPersonQuery } from "@eshg/dental/api/queries/childApi"; import { useDentalApi } from "@eshg/dental/shared/DentalProvider"; @@ -17,16 +18,18 @@ import { ApiCreatePerson } from "@eshg/school-entry-api"; import { Add } from "@mui/icons-material"; import { Button } from "@mui/joy"; import { useRouter } from "next/navigation"; -import { useRef, useState } from "react"; import { SCHOOL_OR_DAYCARE } from "@/lib/baseModule/api/queries/contacts"; import { ChildProcedureCard } from "@/lib/businessModules/dental/features/children/new/ChildProcedureCard"; import { SearchGroupField } from "@/lib/businessModules/dental/features/prophylaxisSessions/SearchGroupField"; import { BUTTON_SIZE } from "@/lib/businessModules/schoolEntry/features/procedures/new/constants"; -import { SidebarFormHandle } from "@/lib/shared/components/form/SidebarForm"; import { SelectContactField } from "@/lib/shared/components/formFields/SelectContactField"; import { SchoolYearField } from "@/lib/shared/components/formFields/schoolYear"; -import { PersonSidebar } from "@/lib/shared/components/personSidebar/PersonSidebar"; +import { + PersonSidebar, + PersonSidebarProps, +} from "@/lib/shared/components/personSidebar/PersonSidebar"; +import { DefaultPersonFormValues } from "@/lib/shared/components/personSidebar/form/DefaultPersonForm"; import { mapToPersonAddRequest } from "@/lib/shared/components/personSidebar/helpers"; import { DefaultSearchPersonForm, @@ -37,9 +40,11 @@ import { SearchPersonFormProps, SearchPersonFormValues, } from "@/lib/shared/components/personSidebar/search/SearchPersonSidebar"; -import { Sidebar } from "@/lib/shared/components/sidebar/Sidebar"; import { getInstitutionOptionLabel } from "@/lib/shared/helpers/selectOptionMapper"; -import { useConfirmationDialog } from "@/lib/shared/hooks/useConfirmationDialog"; +import { + SidebarWithFormRefProps, + useSidebarWithFormRef, +} from "@/lib/shared/hooks/useSidebarWithFormRef"; interface DentalSearchForm extends SearchPersonFormValues { schoolYear: OptionalFieldValue<number>; @@ -88,25 +93,25 @@ function DentalSearchFormComponent( } export function CreateChildSidebar() { - const [open, setOpen] = useState(false); - const router = useRouter(); - const createChild = useCreateChild(); - const sidebarFormRef = useRef<SidebarFormHandle>(null); - const { openCancelDialog } = useConfirmationDialog(); + const personSidebar = useSidebarWithFormRef({ + component: ConfiguredPersonSidebar, + }); - function closeSidebar() { - setOpen(false); - } + return ( + <Button + startDecorator={<Add />} + onClick={() => personSidebar.open()} + size={BUTTON_SIZE} + > + Neues Kind anlegen + </Button> + ); +} - function handleClose() { - if (sidebarFormRef.current?.dirty) { - openCancelDialog({ - onConfirm: closeSidebar, - }); - } else { - closeSidebar(); - } - } +function ConfiguredPersonSidebar(props: SidebarWithFormRefProps) { + const router = useRouter(); + const createChild = useCreateChild(); + const { childApi } = useDentalApi(); async function handleCreate( child: ApiCreatePerson, @@ -118,60 +123,46 @@ export function CreateChildSidebar() { mapToCreateChildRequest(child, schoolYear, institutionId, groupName), { onSuccess: (response) => { - closeSidebar(); router.push(routes.children.byId(response.id).details); }, }, ); } - const { childApi } = useDentalApi(); - return ( - <> - <Button - startDecorator={<Add />} - onClick={() => setOpen(true)} - size={BUTTON_SIZE} - > - Neues Kind anlegen - </Button> + const personSidebarProps: PersonSidebarProps< + DentalSearchForm, + DefaultPersonFormValues, + ApiChild + > = { + title: "Neues Kind anlegen", + onCreate: async ({ searchInputs, createInputs }) => { + await handleCreate( + mapToPersonAddRequest(createInputs), + searchInputs.schoolYear, + searchInputs.institution?.id ?? "", + searchInputs.groupName, + ); + }, + onSelect: async ({ searchInputs, person }) => { + await handleCreate( + mapToPersonAddRequest(person), + searchInputs.schoolYear, + searchInputs.institution?.id ?? "", + searchInputs.groupName, + ); + }, + submitLabel: "Kind anlegen", + searchFormComponent: DentalSearchFormComponent, + initialSearchState: personSearchFormInitialValues, + addressRequired: true, + associatedProcedures: { + getQuery: (personId) => getChildrenByPersonQuery(childApi, personId), + cardComponent: ChildProcedureCard, + }, + ...props, + }; - <Sidebar open={open} onClose={handleClose}> - {open && ( - <PersonSidebar - title={"Neues Kind anlegen"} - onCancel={handleClose} - onCreate={async ({ searchInputs, createInputs }) => { - await handleCreate( - mapToPersonAddRequest(createInputs), - searchInputs.schoolYear, - searchInputs.institution?.id ?? "", - searchInputs.groupName, - ); - }} - onSelect={async ({ searchInputs, person }) => { - await handleCreate( - mapToPersonAddRequest(person), - searchInputs.schoolYear, - searchInputs.institution?.id ?? "", - searchInputs.groupName, - ); - }} - submitLabel={"Kind anlegen"} - sidebarFormRef={sidebarFormRef} - searchFormComponent={DentalSearchFormComponent} - initialSearchState={personSearchFormInitialValues} - addressRequired - associatedProcedures={{ - getQuery: (personId) => - getChildrenByPersonQuery(childApi, personId), - cardComponent: ChildProcedureCard, - }} - /> - )} - </Sidebar> - </> - ); + return <PersonSidebar {...personSidebarProps} />; } function mapToCreateChildRequest( diff --git a/employee-portal/src/lib/businessModules/dental/features/examinations/ChildDetailsSection.tsx b/employee-portal/src/lib/businessModules/dental/features/examinations/ChildDetailsSection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c1f9388737e610fa76ee0fbb1823907f73bb221c --- /dev/null +++ b/employee-portal/src/lib/businessModules/dental/features/examinations/ChildDetailsSection.tsx @@ -0,0 +1,77 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ApiFluoridationConsent } from "@eshg/dental-api"; +import { formatDate } from "@eshg/lib-portal/formatters/dateTime"; +import { formatPersonName } from "@eshg/lib-portal/formatters/person"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Divider, + Stack, +} from "@mui/joy"; +import { differenceInYears } from "date-fns"; + +import { FluoridationConsentInformationSection } from "@/lib/businessModules/dental/shared/FluoridationConsentInformationSection"; +import { DetailsItem } from "@/lib/shared/components/detailsSection/items/DetailsItem"; +import { InformationSheet } from "@/lib/shared/components/infoTile/InformationSheet"; + +interface ChildDetailsSectionProps { + firstName: string; + lastName: string; + dateOfBirth: Date; + dateOfExamination: Date; + groupName: string; + allFluoridationConsents: ApiFluoridationConsent[]; +} + +export function ChildDetailsSection(props: ChildDetailsSectionProps) { + return ( + <InformationSheet> + <Accordion> + <AccordionSummary + sx={{ + fontWeight: 600, + "--variant-plainHoverBg": "transparent", + "--variant-plainActiveBg": "transparent", + }} + > + Details zum Kind + </AccordionSummary> + <AccordionDetails + slotProps={{ + content: { + sx: { paddingTop: 3, paddingBottom: 1, gap: 1 }, + }, + }} + > + <Stack direction="row" gap={3} flexWrap="wrap"> + <DetailsItem + label="Name" + value={formatPersonName({ + firstName: props.firstName, + lastName: props.lastName, + })} + /> + <DetailsItem + label="Geburtstag" + value={formatDate(props.dateOfBirth)} + /> + </Stack> + <DetailsItem label="Gruppe" value={props.groupName} /> + <DetailsItem + label="Alter bei Untersuchung" + value={`${differenceInYears(props.dateOfExamination, props.dateOfBirth)} Jahre`} + /> + <Divider orientation="horizontal" /> + <FluoridationConsentInformationSection + allFluoridationConsents={props.allFluoridationConsents} + /> + </AccordionDetails> + </Accordion> + </InformationSheet> + ); +} diff --git a/employee-portal/src/lib/businessModules/dental/features/examinations/ExaminationFormLayout.tsx b/employee-portal/src/lib/businessModules/dental/features/examinations/ExaminationFormLayout.tsx index 80a268ed7526a3b9f6a0058e478299f489e13e1d..b33f68bafdc3a9273bfee004c2ad6f31cae35c39 100644 --- a/employee-portal/src/lib/businessModules/dental/features/examinations/ExaminationFormLayout.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/examinations/ExaminationFormLayout.tsx @@ -11,8 +11,6 @@ import { Grid } from "@mui/joy"; import { ReactNode } from "react"; import { isDefined } from "remeda"; -import { PageGrid } from "@/lib/shared/components/page/PageGrid"; - import { AdditionalInformationFormValues } from "./AdditionalInformationFormSection"; import { NoteFormValues } from "./NoteFormSection"; @@ -22,23 +20,33 @@ export interface ExaminationFormValues interface ExaminationFormLayoutProps { additionalInformation: ReactNode; + childInformation: ReactNode; dentalExamination?: ReactNode; note: ReactNode; } export function ExaminationFormLayout(props: ExaminationFormLayoutProps) { return ( - <PageGrid> - <Grid xxs={12} md={3}> - {props.additionalInformation} + <Grid container spacing={3}> + <Grid xxs={12} md={3} alignContent="flex-start"> + <Grid container spacing={3} columns={12}> + <Grid xxs={6} md={12}> + {props.additionalInformation} + </Grid> + <Grid xxs={6} md={12}> + {props.childInformation} + </Grid> + </Grid> </Grid> - <Grid container xxs={12} md={9}> - {isDefined(props.dentalExamination) && ( - <Grid xxs={12}>{props.dentalExamination}</Grid> - )} - <Grid xxs={12}>{props.note}</Grid> + <Grid xs={12} md={9} alignContent="flex-start"> + <Grid container spacing={3} columns={12}> + {isDefined(props.dentalExamination) && ( + <Grid xxs={12}>{props.dentalExamination}</Grid> + )} + <Grid xxs={12}>{props.note}</Grid> + </Grid> </Grid> - </PageGrid> + </Grid> ); } diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/CreateProphylaxisSessionSidebar.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/CreateProphylaxisSessionSidebar.tsx index 3766357bd8197593675d82be77e3544f18833461..37af0d88d20c336ae8a7aa2567d257fe0d712c3e 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/CreateProphylaxisSessionSidebar.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/CreateProphylaxisSessionSidebar.tsx @@ -5,6 +5,7 @@ "use client"; +import { ApiDentitionType } from "@eshg/dental-api"; import { useCreateProphylaxisSession } from "@eshg/dental/api/mutations/prophylaxisSessionApi"; import { useSnackbar } from "@eshg/lib-portal/components/snackbar/SnackbarProvider"; import { Formik } from "formik"; @@ -42,6 +43,7 @@ function CreateProphylaxisSessionSidebar(props: SidebarWithFormRefProps) { groupName: "", type: "", isScreening: false, + dentitionType: ApiDentitionType.Mixed, isFluoridation: false, fluoridationVarnish: "", dentistIds: [], diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/ProphylaxisSessionDetails.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/ProphylaxisSessionDetails.tsx index b73e79ce6ed29f375085130ac21339b82d353d0e..e84ec162d5010e895a5e7e03936f5267ef6b391a 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/ProphylaxisSessionDetails.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/ProphylaxisSessionDetails.tsx @@ -13,6 +13,7 @@ import { ProphylaxisSessionParticipantsTable } from "@/lib/businessModules/denta import { useUpdateProphylaxisSessionSidebar } from "@/lib/businessModules/dental/features/prophylaxisSessions/UpdateProphylaxisSessionSidebar"; import { useProphylaxisSessionStore } from "@/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/ProphylaxisSessionStoreProvider"; import { + DENTITION_TYPES, PROPHYLAXIS_TYPES, fluoridationDescription, } from "@/lib/businessModules/dental/features/prophylaxisSessions/translations"; @@ -26,6 +27,9 @@ import { displayBoolean } from "@/lib/shared/helpers/booleans"; export function ProphylaxisSessionDetails() { const prophylaxisSession = useProphylaxisSessionStore((state) => state); const updateProphylaxisSidebar = useUpdateProphylaxisSessionSidebar(); + const detentionType = prophylaxisSession.dentitionType + ? DENTITION_TYPES[prophylaxisSession.dentitionType] + : ""; return ( <Stack gap={4}> @@ -63,6 +67,7 @@ export function ProphylaxisSessionDetails() { label="Reihenuntersuchung" value={displayBoolean(prophylaxisSession.isScreening)} /> + <DetailsItem label="Gebisstyp" value={detentionType} /> <DetailsItem label="Teilnehmer" value={prophylaxisSession.participants.length} diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/ProphylaxisSessionForm.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/ProphylaxisSessionForm.tsx index 64b81425b0ff49af62e081c9bd5c0637b9e54f74..f2de21b5c316b57be379f4de7c92e6dbae3f6d12 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/ProphylaxisSessionForm.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/ProphylaxisSessionForm.tsx @@ -3,7 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ApiFluoridationVarnish, ApiProphylaxisType } from "@eshg/dental-api"; +import { + ApiDentitionType, + ApiFluoridationVarnish, + ApiProphylaxisType, +} from "@eshg/dental-api"; import { Institution } from "@eshg/dental/api/models/Institution"; import { Alert } from "@eshg/lib-portal/components/Alert"; import { SelectField } from "@eshg/lib-portal/components/formFields/SelectField"; @@ -24,11 +28,12 @@ import { AppointmentStaffField, StaffUser, } from "@/lib/shared/components/appointmentBlocks/AppointmentStaffField"; -import { CheckboxField } from "@/lib/shared/components/formFields/CheckboxField"; import { DateTimeField } from "@/lib/shared/components/formFields/DateTimeField"; import { SelectContactField } from "@/lib/shared/components/formFields/SelectContactField"; import { getInstitutionOptionLabel } from "@/lib/shared/helpers/selectOptionMapper"; +import { ScreeningField } from "./ScreeningField"; + interface ProphylaxisSessionFormProps { values: ProphylaxisSessionValues; setFieldValue: (field: "groupName", value: "") => void; @@ -43,6 +48,7 @@ export interface ProphylaxisSessionValues { groupName: string; type: OptionalFieldValue<ApiProphylaxisType>; isScreening: boolean; + dentitionType: OptionalFieldValue<ApiDentitionType>; isFluoridation: boolean; fluoridationVarnish: OptionalFieldValue<ApiFluoridationVarnish>; dentistIds: string[]; @@ -101,11 +107,7 @@ export function ProphylaxisSessionForm(props: ProphylaxisSessionFormProps) { options={PROPHYLAXIS_TYPE_OPTIONS} required="Bitte den Typ der Prophylaxe angeben." /> - <CheckboxField - name="isScreening" - label="Reihenuntersuchung" - disabled={hasExaminationResults} - /> + <ScreeningField screeningDisabled={hasExaminationResults} /> <FluoridationField disabled={hasExaminationResults} /> <Typography component="h3" level="title-sm"> Durchführende Personen @@ -137,6 +139,9 @@ export function mapValues(values: ProphylaxisSessionValues) { groupName: mapRequiredValue(values.groupName), type: mapRequiredValue(values.type), isScreening: values.isScreening, + dentitionType: values.isScreening + ? mapRequiredValue(values.dentitionType) + : undefined, fluoridationVarnish: values.isFluoridation ? mapRequiredValue(values.fluoridationVarnish) : undefined, diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/ProphylaxisSessionParticipantsTable.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/ProphylaxisSessionParticipantsTable.tsx index 0f83d269f31c192d186e811bbe769332ec5d5a2f..504dc261fd70b14d4df9f0496fb9d77550fb94c7 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/ProphylaxisSessionParticipantsTable.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/ProphylaxisSessionParticipantsTable.tsx @@ -61,6 +61,11 @@ const FLUORIDATION_CONSENT_FILTERS: ParticipantFilterDef<FluoridationConsentFilt export function ProphylaxisSessionParticipantsTable() { const prophylaxisSessionId = useProphylaxisSessionStore((state) => state.id); + const isScreening = useProphylaxisSessionStore((state) => state.isScreening); + const isFluoridation = isDefined( + useProphylaxisSessionStore((state) => state.fluoridationVarnish), + ); + const isExamination = isFluoridation || isScreening; const prophylaxisSessionVersion = useProphylaxisSessionStore( (state) => state.version, ); @@ -155,12 +160,16 @@ export function ProphylaxisSessionParticipantsTable() { label="Geschlecht" filters={GENDER_FILTERS} /> - <Divider orientation="vertical" /> - <ParticipantFilter - name="fluoridationConsentGiven" - label="Fluoridierungseinverständnis" - filters={FLUORIDATION_CONSENT_FILTERS} - /> + {isFluoridation && ( + <> + <Divider orientation="vertical" /> + <ParticipantFilter + name="fluoridationConsentGiven" + label="Fluoridierungseinverständnis" + filters={FLUORIDATION_CONSENT_FILTERS} + /> + </> + )} </Stack> </Stack> </> @@ -168,7 +177,7 @@ export function ProphylaxisSessionParticipantsTable() { right={ <> <AddChildButton /> - {filteredParticipants.length > 0 && ( + {isExamination && filteredParticipants.length > 0 && ( <InternalLinkButton href={routeToExamination(0)}> Prophylaxe starten </InternalLinkButton> @@ -180,11 +189,20 @@ export function ProphylaxisSessionParticipantsTable() { > <DataTable data={filteredParticipants} - columns={columnDefs(handleRemoveParticipant, handleAbsentParticipant)} - rowNavigation={{ - focusColumnAccessorKey: "lastName", - route: (row) => routeToExamination(row.index), - }} + columns={columnDefs( + handleRemoveParticipant, + handleAbsentParticipant, + isFluoridation, + isExamination, + )} + rowNavigation={ + isExamination + ? { + focusColumnAccessorKey: "lastName", + route: (row) => routeToExamination(row.index), + } + : undefined + } sorting={tableControl.tableSorting} enableSortingRemoval={false} minWidth={1200} @@ -206,6 +224,8 @@ const columnHelper = createColumnHelper<ChildExamination>(); function columnDefs( onRemoveParticipant: (participantId: string) => void, onAbsentParticipant: (examination: ChildExamination) => void, + isFluoridation: boolean, + isExamination: boolean, ) { return [ columnHelper.accessor("firstName", { @@ -256,24 +276,34 @@ function columnDefs( width: 110, }, }), - columnHelper.accessor("fluoridationConsentGiven", { - header: "Fluoridierungseinverständnis", - cell: (props) => displayBoolean(props.getValue()), - enableSorting: true, - meta: { - canNavigate: { parentRow: true }, - width: 205, - }, - }), - columnHelper.accessor("status", { - header: "Status", - cell: (props) => <ExaminationStatusChip status={props.getValue()} />, - enableSorting: true, - meta: { - canNavigate: { parentRow: true }, - width: 110, - }, - }), + ...(isFluoridation + ? [ + columnHelper.accessor("currentFluoridationConsent", { + header: "Fluoridierungseinverständnis", + cell: (props) => displayBoolean(props.getValue()?.consented), + enableSorting: true, + meta: { + canNavigate: { parentRow: true }, + width: 205, + }, + }), + ] + : []), + ...(isExamination + ? [ + columnHelper.accessor("status", { + header: "Status", + cell: (props) => ( + <ExaminationStatusChip status={props.getValue()} /> + ), + enableSorting: true, + meta: { + canNavigate: { parentRow: true }, + width: 110, + }, + }), + ] + : []), columnHelper.display({ header: "Aktionen", id: "actions", @@ -281,7 +311,7 @@ function columnDefs( childCanBeRemoved(props.row.original) ? ( <ActionsMenu actionItems={[ - ...(props.row.original.status !== "CLOSED" + ...(props.row.original.status !== "CLOSED" && isExamination ? [ { label: "Nicht anwesend", diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/ScreeningField.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/ScreeningField.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cc705955ec2e6c635fd01488b0f6f8ea062a5640 --- /dev/null +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/ScreeningField.tsx @@ -0,0 +1,37 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { SelectField } from "@eshg/lib-portal/components/formFields/SelectField"; +import { Stack } from "@mui/joy"; +import { useField } from "formik"; + +import { DENTITION_TYPE_OPTIONS } from "@/lib/businessModules/dental/features/prophylaxisSessions/options"; +import { CheckboxField } from "@/lib/shared/components/formFields/CheckboxField"; + +interface ScreeningFieldProps { + screeningDisabled?: boolean; +} + +export function ScreeningField(props: ScreeningFieldProps) { + const [isScreening] = useField<boolean>("isScreening"); + + return ( + <Stack gap={3}> + <CheckboxField + name="isScreening" + label="Reihenuntersuchung" + disabled={props.screeningDisabled} + /> + {isScreening.value && ( + <SelectField + name="dentitionType" + label="Gebisstyp" + options={DENTITION_TYPE_OPTIONS} + required="Bitte den Gebisstyp auswählen." + /> + )} + </Stack> + ); +} diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/UpdateProphylaxisSessionSidebar.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/UpdateProphylaxisSessionSidebar.tsx index d0ed6cd5548ea470962208164546f5c2485c474c..49b007c09c384de64aade39939a1f5bd40866ca8 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/UpdateProphylaxisSessionSidebar.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/UpdateProphylaxisSessionSidebar.tsx @@ -57,6 +57,7 @@ function UpdateProphylaxisSessionSidebar( groupName: prophylaxisSession.groupName, type: prophylaxisSession.type, isScreening: prophylaxisSession.isScreening, + dentitionType: parseOptionalValue(prophylaxisSession.dentitionType), isFluoridation: !!prophylaxisSession.fluoridationVarnish, fluoridationVarnish: parseOptionalValue( prophylaxisSession.fluoridationVarnish, diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/AddToothButton.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/AddToothButton.tsx index b3f5c30c3f7aa22ec4da839c92386aef629f8fc1..fe7b01ad98cb4909f1eaf7f0ec034275bc8a8847 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/AddToothButton.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/AddToothButton.tsx @@ -5,7 +5,9 @@ import AddCircleIcon from "@mui/icons-material/AddCircle"; import { IconButton } from "@mui/joy"; +import { styled } from "@mui/joy"; +import { TOOTH_SIZE } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/styles"; import { useDentalExaminationStore } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/DentalExaminationStoreProvider"; import { QuadrantNumber } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/types"; @@ -14,12 +16,21 @@ interface AddToothButtonProps { quadrantNumber: QuadrantNumber; } +export const ToothIconButton = styled(IconButton)({ + padding: 2, + ...TOOTH_SIZE, +}); + +const SizedAddCircleIcon = styled(AddCircleIcon)({ + width: 28, + height: 28, +}); + export function AddToothButton(props: AddToothButtonProps) { const addTooth = useDentalExaminationStore((state) => state.addTooth); return ( - <IconButton - sx={{ padding: 2 }} + <ToothIconButton onClick={() => { addTooth({ quadrantNumber: props.quadrantNumber, @@ -27,7 +38,7 @@ export function AddToothButton(props: AddToothButtonProps) { }); }} > - <AddCircleIcon color="primary" /> - </IconButton> + <SizedAddCircleIcon color="primary" /> + </ToothIconButton> ); } diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/DentalExaminationFormSection.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/DentalExaminationFormSection.tsx index 73b753bd5fdcbbd9c082f788c3ba8110ab897722..174533c452e8096d60c7da5619e401beb5f0e583 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/DentalExaminationFormSection.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/DentalExaminationFormSection.tsx @@ -12,7 +12,7 @@ import { InformationSheet } from "@/lib/shared/components/infoTile/InformationSh export function DentalExaminationFormSection() { return ( - <InformationSheet> + <InformationSheet aria-label="Gebissformular" component="section"> <DentalExaminationJawTabs upperJaw={<UpperJawForm />} lowerJaw={<LowerJawForm />} diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/DentalExaminationJawTabs.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/DentalExaminationJawTabs.tsx index 2de94be3720de15cd170abdf8b75b14ea73c8ada..4deb775dcfc4513877976ac05f312e39f023cbcb 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/DentalExaminationJawTabs.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/DentalExaminationJawTabs.tsx @@ -4,7 +4,7 @@ */ import { Box, Button, Stack, ToggleButtonGroup } from "@mui/joy"; -import { ReactNode } from "react"; +import { MouseEvent, ReactNode } from "react"; import { useDentalExaminationStore } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/DentalExaminationStoreProvider"; import { DentalExaminationView } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/types"; @@ -34,6 +34,15 @@ export function DentalExaminationJawTabs({ } } + function handleChange( + _: MouseEvent<HTMLElement>, + newValue: DentalExaminationView | null, + ) { + if (newValue !== null) { + setView(newValue); + } + } + return ( <Stack alignItems="center" spacing={2}> <ToggleButtonGroup @@ -41,11 +50,12 @@ export function DentalExaminationJawTabs({ color="primary" size="md" value={currentView} - onChange={(_, newValue) => setView(newValue ?? "UPPER_JAW")} + onChange={handleChange} sx={{ width: { xxs: "100%", md: "65%" }, display: "flex", }} + aria-label="Gebiss-Ansicht" > <Button sx={{ flex: "1 1 0%" }} value="UPPER_JAW"> Oberkiefer diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/FullDentitionOverview.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/FullDentitionOverview.tsx index 0ab8b95828d9c7096cf2a88e0297c936583aa6c4..5a4955d443df6a261cfc609a298cbadd2bb6fe56 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/FullDentitionOverview.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/FullDentitionOverview.tsx @@ -3,8 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Button, Grid, Stack, Typography } from "@mui/joy"; +import { Button, Grid, GridProps, Stack, Typography } from "@mui/joy"; import { SxProps } from "@mui/joy/styles/types"; +import { useId } from "react"; import { Quadrant } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/Quadrant"; import { @@ -21,33 +22,60 @@ import { } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/types"; export function FullDentitionOverview() { + const upperJawRightId = useId(); + const upperJawLeftId = useId(); + const lowerJawRightId = useId(); + const lowerJawLeftId = useId(); return ( <Stack> <QuadrantHeadingRow marginBottom="24px"> - <QuadrantHeading name="Oberkiefer rechts" index={1} /> - <QuadrantHeading name="Oberkiefer links" index={2} /> + <QuadrantHeading + name="Oberkiefer rechts" + index={1} + id={upperJawRightId} + /> + <QuadrantHeading + name="Oberkiefer links" + index={2} + id={upperJawLeftId} + /> </QuadrantHeadingRow> <Grid container> - <QuadrantSection quadrantNumber="Q1" /> - <QuadrantSection quadrantNumber="Q2" /> + <QuadrantSection + quadrantNumber="Q1" + aria-labelledby={upperJawRightId} + /> + <QuadrantSection quadrantNumber="Q2" aria-labelledby={upperJawLeftId} /> </Grid> <Grid container> - <QuadrantSection quadrantNumber="Q4" /> - <QuadrantSection quadrantNumber="Q3" /> + <QuadrantSection + quadrantNumber="Q4" + aria-labelledby={lowerJawRightId} + /> + <QuadrantSection quadrantNumber="Q3" aria-labelledby={lowerJawLeftId} /> </Grid> <QuadrantHeadingRow> - <QuadrantHeading name="Unterkiefer rechts" index={4} /> - <QuadrantHeading name="Unterkiefer links" index={3} /> + <QuadrantHeading + name="Unterkiefer rechts" + index={4} + id={lowerJawRightId} + /> + <QuadrantHeading + name="Unterkiefer links" + index={3} + id={lowerJawLeftId} + /> </QuadrantHeadingRow> </Stack> ); } -interface QuadrantSectionProps { +interface QuadrantSectionProps extends GridProps { quadrantNumber: QuadrantNumber; } -function QuadrantSection({ quadrantNumber }: QuadrantSectionProps) { +function QuadrantSection(props: QuadrantSectionProps) { + const quadrantNumber = props.quadrantNumber; const styles: SxProps = { padding: quadrantNumber === "Q1" || quadrantNumber === "Q4" @@ -73,7 +101,7 @@ function QuadrantSection({ quadrantNumber }: QuadrantSectionProps) { const setFocus = useDentalExaminationStore((state) => state.setFocus); return ( - <Grid xxs={6} sx={styles}> + <Grid {...props} xxs={6} sx={styles} component="section"> <Quadrant quadrantNumber={quadrantNumber} gap={0}> {(tooth, index) => ( <Button @@ -99,7 +127,10 @@ function QuadrantSection({ quadrantNumber }: QuadrantSectionProps) { } > <ToothNumber tooth={tooth} /> - <ToothIcon tooth={tooth} /> + <ToothIcon + tooth={tooth} + toothContext={{ quadrantNumber, toothIndex: index }} + /> <ExaminationResult tooth={tooth} /> </Button> )} diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/GeneralJawForm.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/GeneralJawForm.tsx deleted file mode 100644 index 62657d112890bc2c0737a893ee34610f4e748e3b..0000000000000000000000000000000000000000 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/GeneralJawForm.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { ApiMainResult } from "@eshg/dental-api"; -import { isEmptyString } from "@eshg/lib-portal/helpers/guards"; -import { Stack, Typography } from "@mui/joy"; - -import { AddToothButton } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/AddToothButton"; -import { Quadrant } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/Quadrant"; -import { ToothIcon } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/Teeth"; -import { ToothNumber } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ToothNumber"; -import { useDentalExaminationStore } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/DentalExaminationStoreProvider"; -import { - QuadrantNumber, - ToothWithDiagnosis, - isAddableTooth, - isToothWithDiagnosis, -} from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/types"; - -import { ResultInputField } from "./ResultInputField"; - -export function GeneralJawForm(props: { quadrantNumber: QuadrantNumber }) { - const setMainResult = useDentalExaminationStore( - (state) => state.setMainResult, - ); - const setSecondaryResult1 = useDentalExaminationStore( - (state) => state.setSecondaryResult1, - ); - const setSecondaryResult2 = useDentalExaminationStore( - (state) => state.setSecondaryResult2, - ); - - return ( - <Quadrant quadrantNumber={props.quadrantNumber}> - {(tooth, index) => ( - <Stack key={tooth.toothNumber} sx={{ gap: 2, alignItems: "center" }}> - {isToothWithDiagnosis(tooth) && ( - <> - <ToothNumber tooth={tooth} /> - <ToothIcon tooth={tooth} /> - <ResultInputField - result={tooth.mainResult} - index={index} - quadrantNumber={props.quadrantNumber} - setResultAction={setMainResult} - field="main" - variant={ - isEmptyString(tooth.mainResult.value) ? "soft" : "outlined" - } - /> - <ResultInputField - result={tooth.secondaryResult1} - index={index} - quadrantNumber={props.quadrantNumber} - setResultAction={setSecondaryResult1} - field="secondary1" - /> - <ResultInputField - result={tooth.secondaryResult2} - index={index} - quadrantNumber={props.quadrantNumber} - setResultAction={setSecondaryResult2} - field="secondary2" - /> - {hasPreviousExaminationResult(tooth) && ( - <Typography color="danger"> - {tooth.previousResults.join(",")} - </Typography> - )} - </> - )} - {isAddableTooth(tooth) && ( - <AddToothButton - index={index} - quadrantNumber={props.quadrantNumber} - /> - )} - </Stack> - )} - </Quadrant> - ); -} - -export function hasPreviousExaminationResult( - tooth: ToothWithDiagnosis, -): boolean { - return ( - tooth.previousResults.length > 0 && - tooth.previousResults[0] !== ApiMainResult.S - ); -} diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/LowerJawForm.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/LowerJawForm.tsx index 947c229ba3617295bd80bd033f58332f6ed552bf..8954fe37a0055de330322a91e66724e24848165b 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/LowerJawForm.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/LowerJawForm.tsx @@ -3,24 +3,37 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { GeneralJawForm } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/GeneralJawForm"; +import { useId } from "react"; + import { JawWithHeading } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/JawWithHeading"; import { QuadrantHeading, QuadrantHeadingRow, } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/QuadrantHeading"; +import { Quadrant } from "./Quadrant"; + export function LowerJawForm() { + const lowerJawRightId = useId(); + const lowerJawLeftId = useId(); return ( <JawWithHeading heading={ <QuadrantHeadingRow marginBottom="24px"> - <QuadrantHeading name="Unterkiefer rechts" index={4} /> - <QuadrantHeading name="Unterkiefer links" index={3} /> + <QuadrantHeading + name="Unterkiefer rechts" + index={4} + id={lowerJawRightId} + /> + <QuadrantHeading + name="Unterkiefer links" + index={3} + id={lowerJawLeftId} + /> </QuadrantHeadingRow> } - left={<GeneralJawForm quadrantNumber="Q4" />} - right={<GeneralJawForm quadrantNumber="Q3" />} + left={<Quadrant quadrantNumber="Q4" aria-labelledby={lowerJawRightId} />} + right={<Quadrant quadrantNumber="Q3" aria-labelledby={lowerJawLeftId} />} /> ); } diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/Quadrant.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/Quadrant.tsx index d8510a2a3a83c3b4ca51f329e8946c239dd23444..af76e6cf40c78af4e62711cc7969421f23472bee 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/Quadrant.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/Quadrant.tsx @@ -13,18 +13,34 @@ import { Tooth, } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/types"; +import { ToothColumn } from "./ToothColumn"; + interface QuadrantProps { quadrantNumber: QuadrantNumber; - children: (tooth: Tooth, index: number) => ReactNode; + children?: (tooth: Tooth, index: number) => ReactNode; gap?: Property.Gap; + "aria-labelledby"?: string; } export function Quadrant(props: QuadrantProps) { const dentition = useDentalExaminationStore((state) => state.dentition); return ( - <Stack gap={props.gap ?? 1} direction="row"> - {dentition[props.quadrantNumber].teeth.map((tooth, index) => - props.children(tooth, index), + <Stack + component="section" + gap={props.gap ?? 1} + direction="row" + aria-labelledby={props["aria-labelledby"]} + > + {dentition[props.quadrantNumber].teeth.map( + (tooth, index) => + props.children?.(tooth, index) ?? ( + <ToothColumn + key={tooth.toothNumber} + tooth={tooth} + index={index} + quadrantNumber={props.quadrantNumber} + /> + ), )} </Stack> ); diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/QuadrantHeading.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/QuadrantHeading.tsx index 73f26ea9e6920a391c795b3ba134759e60a9c035..eb290e4e8758d4594ecc813f70965717ce5df4b3 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/QuadrantHeading.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/QuadrantHeading.tsx @@ -29,9 +29,13 @@ export function QuadrantHeadingRow(props: QuadrantHeadingRowProps) { ); } -export function QuadrantHeading(props: { name: string; index: number }) { +export function QuadrantHeading(props: { + name: string; + index: number; + id?: string; +}) { return ( - <Typography component="h3"> + <Typography component="h3" id={props.id}> <Typography component="span" sx={{ diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/RemoveToothButton.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/RemoveToothButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..950f635ab6057a96d8077e2899d3b452e21b8e25 --- /dev/null +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/RemoveToothButton.tsx @@ -0,0 +1,46 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { DeleteOutlined } from "@mui/icons-material"; +import { styled } from "@mui/joy"; + +import { ToothIconButton } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/AddToothButton"; +import { useDentalExaminationStore } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/DentalExaminationStoreProvider"; +import { ToothContext } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/types"; + +interface RemoveToothButtonProps { + toothContext: ToothContext; +} + +const DeleteIconButton = styled(ToothIconButton)({ + position: "absolute", + top: 0, + right: 0, +}); + +const RoundedDeleteIcon = styled(DeleteOutlined)(({ theme }) => ({ + padding: 4, + borderRadius: "50%", + color: theme.palette.common.white, + backgroundColor: theme.palette.danger.solidBg, +})); + +export function RemoveToothButton(props: RemoveToothButtonProps) { + const removeTooth = useDentalExaminationStore((state) => state.removeTooth); + + return ( + <DeleteIconButton + color="danger" + variant="plain" + className="remove-tooth-button" + onClick={() => { + removeTooth(props.toothContext); + }} + aria-label={"Zahn entfernen"} + > + <RoundedDeleteIcon /> + </DeleteIconButton> + ); +} diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ResultInputField.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ResultInputField.tsx index aff3bf8b7b3c44f4ee870b9fd2d9511f32739288..9583aefb40260f6f95308ce18bcce2aced4a7c9d 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ResultInputField.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ResultInputField.tsx @@ -3,57 +3,52 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Input, VariantProp } from "@mui/joy"; +import { Input, InputProps, VariantProp } from "@mui/joy"; import { useEffect, useRef } from "react"; +import { isDefined } from "remeda"; +import { useShallow } from "zustand/react/shallow"; import { useDentalExaminationStore } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/DentalExaminationStoreProvider"; +import { NAVIGATE_DIRECTIONS } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/constants"; import { SetToothResultAction } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/dentalExaminationStore"; import { - FieldVariant, - QuadrantNumber, + ElementContext, + ResultField, + ToothContext, ToothResult, } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/types"; -interface ResultInputFieldProps { - quadrantNumber: QuadrantNumber; - index: number; - setResultAction: SetToothResultAction; - field: FieldVariant; +interface ResultInputFieldProps extends InputProps { + field: ResultField; result: ToothResult; + toothContext: ToothContext; variant?: VariantProp; + setResultAction: SetToothResultAction; } export function ResultInputField(props: ResultInputFieldProps) { - const focus = useDentalExaminationStore((state) => state.focus); + const elementContext: ElementContext = { + field: props.field, + toothContext: props.toothContext, + }; + const isFocused = useIsFocused(elementContext); const setFocus = useDentalExaminationStore((state) => state.setFocus); + const navigate = useDentalExaminationStore((state) => state.navigate); const input = useRef<HTMLInputElement>(null); - const { quadrantNumber, toothIndex } = focus.toothContext; - const focusReferencesThisInput = - quadrantNumber === props.quadrantNumber && - toothIndex === props.index && - focus.field === props.field; - useEffect(() => { - if (focusReferencesThisInput) { + if (isFocused) { input?.current?.focus(); } - }, [input, focusReferencesThisInput]); + }, [input, isFocused]); function handleOnFocus() { - if (!focusReferencesThisInput) { - setFocus({ - toothContext: { - quadrantNumber: props.quadrantNumber, - toothIndex: props.index, - }, - field: props.field, - }); - } + setFocus(elementContext); } return ( <Input + {...props} slotProps={{ input: { ref: input } }} value={props.result.value} sx={{ width: 60 }} @@ -63,13 +58,36 @@ export function ResultInputField(props: ResultInputFieldProps) { onFocus={handleOnFocus} onChange={(event) => { props.setResultAction( - { - quadrantNumber: props.quadrantNumber, - toothIndex: props.index, - }, + props.toothContext, event.target.value.toUpperCase(), ); }} + onKeyDown={(event) => { + const direction = NAVIGATE_DIRECTIONS[event.code]; + + if (isDefined(direction)) { + navigate(direction); + } + }} /> ); } + +function useIsFocused(element: ElementContext) { + return useDentalExaminationStore( + useShallow((state) => equalsElement(element, state.currentFocus)), + ); +} + +function equalsElement( + elementContext: ElementContext, + currentFocus: ElementContext, +): boolean { + return ( + currentFocus.toothContext.quadrantNumber === + elementContext.toothContext.quadrantNumber && + currentFocus.toothContext.toothIndex === + elementContext.toothContext.toothIndex && + currentFocus.field === elementContext.field + ); +} diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/Teeth.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/Teeth.tsx index 0000aaf6e33aed8ac5fe320744f7b5864e8a1ff8..ec796d5d0ac6a1d4afd74fa2d5bf0578f35e0a5f 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/Teeth.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/Teeth.tsx @@ -6,24 +6,21 @@ "use client"; import ClearIcon from "@mui/icons-material/Clear"; -import { Box } from "@mui/joy"; +import { Box, styled } from "@mui/joy"; import SvgIcon from "@mui/joy/SvgIcon"; -import { SxProps } from "@mui/joy/styles/types"; import { theme } from "@/lib/baseModule/theme/theme"; +import { RemoveToothButton } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/RemoveToothButton"; +import { TOOTH_SIZE } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/styles"; import { Tooth, + ToothContext, + hasPreviousExaminationResult, isInUpperJaw, isToothWithDiagnosis, } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/types"; -import { hasPreviousExaminationResult } from "./GeneralJawForm"; - const FILL_COLOR = "#555E68"; -const ICON_SIZE: SxProps = { - width: 60, - height: 66, -}; type ToothKey = keyof typeof TOOTH_COMPONENTS; @@ -40,9 +37,10 @@ const TOOTH_COMPONENTS = { interface ToothProps { tooth: Tooth; + toothContext: ToothContext; } -export function ToothIcon({ tooth }: ToothProps) { +export function ToothIcon({ tooth, toothContext }: ToothProps) { const inUpperJaw = isInUpperJaw(tooth); if (!isToothWithDiagnosis(tooth)) { @@ -51,13 +49,14 @@ export function ToothIcon({ tooth }: ToothProps) { const toothKey = getToothKey(tooth); const variant = inUpperJaw ? "upperJaw" : "lowerJaw"; - const ToothIcon = TOOTH_COMPONENTS[toothKey]; + const ToothIconComponent = TOOTH_COMPONENTS[toothKey]; return ( - <ToothIcon + <ToothIconComponent variant={variant} isPrimaryTooth={tooth.toothType === "PRIMARY_TOOTH"} hasPreviousExaminationResult={hasPreviousExaminationResult(tooth)} + toothContext={toothContext} /> ); } @@ -66,11 +65,17 @@ interface ToothIconProps { hasPreviousExaminationResult?: boolean; isPrimaryTooth?: boolean; variant: "upperJaw" | "lowerJaw"; + toothContext: ToothContext; } export function Incisor(props: ToothIconProps) { return ( - <SvgIcon sx={ICON_SIZE} viewBox="0 0 60 66" fill="none"> + <SvgIcon + sx={TOOTH_SIZE} + viewBox="0 0 60 66" + fill="none" + data-testid="tooth-icon" + > <g transform={props.variant === "upperJaw" ? "" : "rotate(180, 30, 33)"}> <path d="M30.8944 12.0249L34.6584 19.5528C34.9908 20.2177 34.5073 21 33.7639 21H26.2361C25.4927 21 25.0092 20.2177 25.3416 19.5528L29.1056 12.0249C29.4741 11.2879 30.5259 11.2879 30.8944 12.0249Z" @@ -107,7 +112,12 @@ export function Incisor(props: ToothIconProps) { export function Premolar(props: ToothIconProps) { return ( - <SvgIcon sx={ICON_SIZE} viewBox="0 0 60 66" fill="none"> + <SvgIcon + sx={TOOTH_SIZE} + viewBox="0 0 60 66" + fill="none" + data-testid="tooth-icon" + > <g transform={props.variant === "upperJaw" ? "" : "rotate(180, 30, 33)"}> <path d="M22.8944 4.02492L26.6584 11.5528C26.9908 12.2177 26.5073 13 25.7639 13H18.2361C17.4927 13 17.0092 12.2177 17.3416 11.5528L21.1056 4.02492C21.4741 3.28787 22.5259 3.28787 22.8944 4.02492Z" @@ -150,7 +160,12 @@ export function Premolar(props: ToothIconProps) { export function Cuspid(props: ToothIconProps) { return ( - <SvgIcon sx={ICON_SIZE} viewBox="0 0 60 66" fill="none"> + <SvgIcon + sx={TOOTH_SIZE} + viewBox="0 0 60 66" + fill="none" + data-testid="tooth-icon" + > <g transform={props.variant === "upperJaw" ? "" : "rotate(180, 30, 33)"}> <path d="M30.8944 4.02492L34.6584 11.5528C34.9908 12.2177 34.5073 13 33.7639 13H26.2361C25.4927 13 25.0092 12.2177 25.3416 11.5528L29.1056 4.02492C29.4741 3.28787 30.5259 3.28787 30.8944 4.02492Z" @@ -185,33 +200,49 @@ export function Cuspid(props: ToothIconProps) { ); } +const ToothSizedContainer = styled("div")({ + ...TOOTH_SIZE, + position: "relative", + ".remove-tooth-button": { + display: "none", + }, + "&:hover .remove-tooth-button": { + display: "inline-flex", + }, +}); + export function Molar(props: ToothIconProps) { return ( - <SvgIcon sx={ICON_SIZE} viewBox="0 0 60 66" fill="none"> + <SvgIcon + sx={TOOTH_SIZE} + viewBox="0 0 60 66" + fill="none" + data-testid="tooth-icon" + > <g transform={props.variant === "upperJaw" ? "" : "rotate(180, 30, 33)"}> <path d="M14.8944 4.02492L18.6584 11.5528C18.9908 12.2177 18.5073 13 17.7639 13H10.2361C9.49269 13 9.00919 12.2177 9.34164 11.5528L13.1056 4.02492C13.4741 3.28787 14.5259 3.28787 14.8944 4.02492Z" fill={props.isPrimaryTooth ? "white" : FILL_COLOR} stroke={FILL_COLOR} - stroke-width="2" + strokeWidth="2" /> <path d="M30.8944 4.02492L34.6584 11.5528C34.9908 12.2177 34.5073 13 33.7639 13H26.2361C25.4927 13 25.0092 12.2177 25.3416 11.5528L29.1056 4.02492C29.4741 3.28787 30.5259 3.28787 30.8944 4.02492Z" fill={props.isPrimaryTooth ? "white" : FILL_COLOR} stroke={FILL_COLOR} - stroke-width="2" + strokeWidth="2" /> <path d="M46.8944 4.02492L50.6584 11.5528C50.9908 12.2177 50.5073 13 49.7639 13H42.2361C41.4927 13 41.0092 12.2177 41.3416 11.5528L45.1056 4.02492C45.4741 3.28787 46.5259 3.28787 46.8944 4.02492Z" fill={props.isPrimaryTooth ? "white" : FILL_COLOR} stroke={FILL_COLOR} - stroke-width="2" + strokeWidth="2" /> <path d="M1 26C1 22.134 4.13401 19 8 19H52C55.866 19 59 22.134 59 26V58C59 61.866 55.866 65 52 65H8C4.13401 65 1 61.866 1 58V26Z" fill={props.isPrimaryTooth ? "white" : FILL_COLOR} stroke={FILL_COLOR} - stroke-width="2" + strokeWidth="2" /> {props.hasPreviousExaminationResult && ( <g @@ -234,6 +265,15 @@ export function Molar(props: ToothIconProps) { ); } +export function RemovableToothIcon(props: ToothProps) { + return ( + <ToothSizedContainer data-testid="tooth-icon-button"> + <ToothIcon {...props} /> + <RemoveToothButton toothContext={props.toothContext} /> + </ToothSizedContainer> + ); +} + interface NoToothIconProps { isInUpperJaw: boolean; } @@ -242,7 +282,7 @@ function NoToothIcon(props: NoToothIconProps) { return ( <Box sx={{ - ...ICON_SIZE, + ...TOOTH_SIZE, padding: props.isInUpperJaw ? "32px 18px 10px 18px" : "10px 18px 32px 18px", diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ToothColumn.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ToothColumn.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5a06eb2c29400a27ffbd6cb74596700373289936 --- /dev/null +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ToothColumn.tsx @@ -0,0 +1,46 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Stack } from "@mui/joy"; + +import { AddToothButton } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/AddToothButton"; +import { ToothForm } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ToothForm"; +import { ToothNumber } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ToothNumber"; +import { + QuadrantNumber, + Tooth, + isToothWithDiagnosis, +} from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/types"; + +interface ToothColumnProps { + quadrantNumber: QuadrantNumber; + tooth: Tooth; + index: number; +} + +export function ToothColumn({ + tooth, + index, + quadrantNumber, +}: ToothColumnProps) { + return ( + <Stack + component="fieldset" + key={tooth.toothNumber} + sx={{ gap: 2, alignItems: "center", padding: 0, margin: 0, border: 0 }} + > + <ToothNumber tooth={tooth} sx={{ marginBottom: 2 }} /> + {isToothWithDiagnosis(tooth) ? ( + <ToothForm + quadrantNumber={quadrantNumber} + index={index} + tooth={tooth} + /> + ) : ( + <AddToothButton index={index} quadrantNumber={quadrantNumber} /> + )} + </Stack> + ); +} diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ToothForm.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ToothForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..106833dbdda3a3fb841cfcc0f4d2d591344e9367 --- /dev/null +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ToothForm.tsx @@ -0,0 +1,78 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { isEmptyString } from "@eshg/lib-portal/helpers/guards"; +import { Typography } from "@mui/joy"; + +import { ResultInputField } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ResultInputField"; +import { + RemovableToothIcon, + ToothIcon, +} from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/Teeth"; +import { useDentalExaminationStore } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/DentalExaminationStoreProvider"; +import { + QuadrantNumber, + ToothContext, + ToothWithDiagnosis, + hasPreviousExaminationResult, +} from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/types"; + +interface ToothFormProps { + quadrantNumber: QuadrantNumber; + index: number; + tooth: ToothWithDiagnosis; +} + +export function ToothForm(props: ToothFormProps) { + const { tooth, quadrantNumber, index } = props; + const toothContext: ToothContext = { quadrantNumber, toothIndex: index }; + + const setMainResult = useDentalExaminationStore( + (state) => state.setMainResult, + ); + const setSecondaryResult1 = useDentalExaminationStore( + (state) => state.setSecondaryResult1, + ); + const setSecondaryResult2 = useDentalExaminationStore( + (state) => state.setSecondaryResult2, + ); + + return ( + <> + {tooth.isRemovable ? ( + <RemovableToothIcon tooth={tooth} toothContext={toothContext} /> + ) : ( + <ToothIcon tooth={tooth} toothContext={toothContext} /> + )} + <ResultInputField + result={tooth.mainResult} + toothContext={toothContext} + setResultAction={setMainResult} + field="main" + variant={isEmptyString(tooth.mainResult.value) ? "soft" : "outlined"} + aria-label="Hauptbefund" + /> + <ResultInputField + result={tooth.secondaryResult1} + toothContext={toothContext} + setResultAction={setSecondaryResult1} + field="secondary1" + aria-label="Nebenbefund 1" + /> + <ResultInputField + result={tooth.secondaryResult2} + toothContext={toothContext} + setResultAction={setSecondaryResult2} + field="secondary2" + aria-label="Nebenbefund 2" + /> + {hasPreviousExaminationResult(tooth) && ( + <Typography color="danger"> + {tooth.previousResults.join(",")} + </Typography> + )} + </> + ); +} diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ToothNumber.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ToothNumber.tsx index 16a5fa0ad68822c6a0444053d37e20dcfc961299..3703a7d0886f2f509bf02466334fef68ce733b00 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ToothNumber.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/ToothNumber.tsx @@ -4,13 +4,20 @@ */ import { Typography } from "@mui/joy"; +import { SxProps } from "@mui/joy/styles/types"; import { theme } from "@/lib/baseModule/theme/theme"; import { Tooth } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/types"; -export function ToothNumber(props: { tooth: Tooth }) { +interface ToothNumberProps { + tooth: Tooth; + sx?: SxProps; +} + +export function ToothNumber(props: ToothNumberProps) { return ( <Typography + component="legend" sx={{ fontSize: theme.fontSize.md, borderRadius: theme.radius.sm, @@ -19,6 +26,10 @@ export function ToothNumber(props: { tooth: Tooth }) { width: 36, height: 24, textAlign: "center", + //marginLeft and -Right needs to be set for firefox + marginRight: "auto", + marginLeft: "auto", + ...props.sx, }} > {getToothNumber(props.tooth)} diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/UpperJawForm.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/UpperJawForm.tsx index 32238f88c674dfff23b3f02dda76f5b9d100bd60..0ed5e15d1f3758d5daae0b5ee73b1b0d9ae411c4 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/UpperJawForm.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/UpperJawForm.tsx @@ -3,24 +3,37 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { GeneralJawForm } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/GeneralJawForm"; +import { useId } from "react"; + import { JawWithHeading } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/JawWithHeading"; import { QuadrantHeading, QuadrantHeadingRow, } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/QuadrantHeading"; +import { Quadrant } from "./Quadrant"; + export function UpperJawForm() { + const upperJawRightId = useId(); + const upperJawLeftId = useId(); return ( <JawWithHeading heading={ <QuadrantHeadingRow marginBottom="24px"> - <QuadrantHeading name="Oberkiefer rechts" index={1} /> - <QuadrantHeading name="Oberkiefer links" index={2} /> + <QuadrantHeading + name="Oberkiefer rechts" + index={1} + id={upperJawRightId} + /> + <QuadrantHeading + name="Oberkiefer links" + index={2} + id={upperJawLeftId} + /> </QuadrantHeadingRow> } - left={<GeneralJawForm quadrantNumber="Q1" />} - right={<GeneralJawForm quadrantNumber="Q2" />} + left={<Quadrant quadrantNumber="Q1" aria-labelledby={upperJawRightId} />} + right={<Quadrant quadrantNumber="Q2" aria-labelledby={upperJawLeftId} />} /> ); } diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/styles.ts b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/styles.ts new file mode 100644 index 0000000000000000000000000000000000000000..117d2838974b94e275576fc8476ef5760e41a1c6 --- /dev/null +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/styles.ts @@ -0,0 +1,9 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const TOOTH_SIZE = { + width: 60, + height: 66, +}; diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/actions.ts b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/actions.ts index 574503a4a92dd282a66baa0e83711a6effc87eed..ac4b8328db94a8a8a28fae70c7668a656d1f58c9 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/actions.ts +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/actions.ts @@ -7,11 +7,13 @@ import { ApiMainResult, ApiSecondaryResult } from "@eshg/dental-api"; import { ToothDiagnoses } from "@eshg/dental/api/models/ExaminationResult"; import { isEmptyString } from "@eshg/lib-portal/helpers/guards"; +import { DentalExaminationState } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/dentalExaminationStore"; + import { createToothResult, createToothWithDiagnosis } from "./factories"; import { - DentalExaminationView, + AddableTooth, Dentition, - Focus, + ElementContext, ToothContext, ToothResult, ToothWithDiagnosis, @@ -144,6 +146,42 @@ export function addTooth( }; } +export function removeTooth( + toothContext: ToothContext, + dentition: Dentition, +): Dentition { + const { quadrantNumber, toothIndex } = toothContext; + const targetQuadrant = dentition[quadrantNumber]; + const tooth = targetQuadrant.teeth[toothIndex]; + + if (tooth === undefined) { + throw new Error( + `Tooth with index ${toothIndex} does not exist in quadrant ${quadrantNumber}`, + ); + } + + if (tooth.type !== "ToothWithDiagnosis") { + throw new Error("Tooth must be of type ToothWithDiagnosis"); + } + + if (!tooth.isRemovable) { + throw new Error("Tooth is not removable"); + } + + const newTooth: AddableTooth = { + type: "AddableTooth", + toothNumber: tooth.toothNumber, + }; + + return { + ...dentition, + [quadrantNumber]: { + ...targetQuadrant, + teeth: targetQuadrant.teeth.with(toothContext.toothIndex, newTooth), + }, + }; +} + function updateToothWithDiagnosis( toothContext: ToothContext, dentition: Dentition, @@ -227,17 +265,16 @@ function isEmptyToothResult(toothResult: ToothResult): boolean { return toothResult.value === ""; } -export function setFocus(focus: Focus): { - focus: Focus; - currentView: DentalExaminationView; -} { - const quadrantNumber = focus.toothContext.quadrantNumber; +type FocusState = Pick<DentalExaminationState, "currentView" | "currentFocus">; + +export function setFocus(newFocus: ElementContext): FocusState { + const quadrantNumber = newFocus.toothContext.quadrantNumber; const nextView = quadrantNumber === "Q1" || quadrantNumber === "Q2" ? "UPPER_JAW" : "LOWER_JAW"; return { - focus, + currentFocus: newFocus, currentView: nextView, }; } diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/actions/navigate.ts b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/actions/navigate.ts new file mode 100644 index 0000000000000000000000000000000000000000..c70bbc6c56f90f0db249e103080453aec1aeb6ea --- /dev/null +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/actions/navigate.ts @@ -0,0 +1,189 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + MAX_TOOTH_INDEX, + MIN_TOOTH_INDEX, +} from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/constants"; +import type { DentalExaminationState } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/dentalExaminationStore"; +import { + DentalExaminationView, + ElementContext, + QuadrantNumber, + ResultField, +} from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/types"; + +export type NavigateDirection = "UP" | "DOWN" | "LEFT" | "RIGHT"; +export type NavigateState = Pick< + DentalExaminationState, + "currentView" | "currentFocus" +>; + +export function navigate( + direction: NavigateDirection, + state: NavigateState, +): NavigateState { + switch (direction) { + case "UP": + return navigateUp(state); + case "DOWN": + return navigateDown(state); + case "LEFT": + return navigateLeft(state); + case "RIGHT": + return navigateRight(state); + } +} + +function navigateUp(state: NavigateState): NavigateState { + const { currentView, currentFocus } = state; + const { field, toothContext } = currentFocus; + + if (currentView === "FULL_DENTITION" || field === undefined) { + return { currentView, currentFocus }; + } + + switch (field) { + case "main": + return { currentView, currentFocus }; + case "secondary1": + return navigateToTooth(currentView, { field: "main", toothContext }); + case "secondary2": + return navigateToTooth(currentView, { + field: "secondary1", + toothContext, + }); + } +} + +function navigateDown(state: NavigateState): NavigateState { + const { currentView, currentFocus } = state; + const { field, toothContext } = currentFocus; + + if (currentView === "FULL_DENTITION" || field === undefined) { + return { currentView, currentFocus }; + } + + switch (field) { + case "main": + return navigateToTooth(currentView, { + field: "secondary1", + toothContext, + }); + case "secondary1": + return navigateToTooth(currentView, { + field: "secondary2", + toothContext, + }); + case "secondary2": + return { currentView, currentFocus }; + } +} + +function navigateLeft(state: NavigateState): NavigateState { + const { currentView, currentFocus } = state; + const { quadrantNumber, toothIndex } = currentFocus.toothContext; + + if (toothIndex > MIN_TOOTH_INDEX) { + return navigateToTooth(currentView, { + field: defaultField(currentView), + toothContext: { + quadrantNumber, + toothIndex: toothIndex - 1, + }, + }); + } + + if (quadrantNumber === "Q1" && currentView === "FULL_DENTITION") { + return navigateToFirstTooth("LOWER_JAW", "Q4"); + } + + if (quadrantNumber === "Q2") { + return navigateToLastTooth(currentView, "Q1"); + } + + if (quadrantNumber === "Q3") { + return navigateToLastTooth(currentView, "Q4"); + } + + if (quadrantNumber === "Q4" && currentView !== "FULL_DENTITION") { + return navigateToFirstTooth("FULL_DENTITION", "Q1"); + } + + return { currentView, currentFocus }; +} + +function navigateRight(state: NavigateState): NavigateState { + const { currentView, currentFocus } = state; + const { quadrantNumber, toothIndex } = currentFocus.toothContext; + + if (toothIndex < MAX_TOOTH_INDEX) { + return navigateToTooth(currentView, { + field: defaultField(currentView), + toothContext: { + toothIndex: toothIndex + 1, + quadrantNumber, + }, + }); + } + + if (quadrantNumber === "Q1") { + return navigateToFirstTooth(currentView, "Q2"); + } + + if (quadrantNumber === "Q2") { + return navigateToLastTooth("LOWER_JAW", "Q3"); + } + + if (quadrantNumber === "Q3") { + return navigateToLastTooth("UPPER_JAW", "Q2"); + } + + if (quadrantNumber === "Q4") { + return navigateToFirstTooth(currentView, "Q3"); + } + + return { currentView, currentFocus }; +} + +function navigateToTooth( + view: DentalExaminationView, + element: ElementContext, +): NavigateState { + return { + currentView: view, + currentFocus: element, + }; +} + +function navigateToFirstTooth( + view: DentalExaminationView, + quadrantNumber: QuadrantNumber, +): NavigateState { + return navigateToTooth(view, { + field: defaultField(view), + toothContext: { + quadrantNumber, + toothIndex: MIN_TOOTH_INDEX, + }, + }); +} + +function navigateToLastTooth( + view: DentalExaminationView, + quadrantNumber: QuadrantNumber, +): NavigateState { + return navigateToTooth(view, { + field: defaultField(view), + toothContext: { + quadrantNumber, + toothIndex: MAX_TOOTH_INDEX, + }, + }); +} + +function defaultField(view: DentalExaminationView): ResultField | undefined { + return view === "FULL_DENTITION" ? undefined : "main"; +} diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/constants.ts b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/constants.ts index a4f548ed9705ed762c6c0ab4a6c3651c2c399007..8badadf4396b2db4d4697fb0919d962f1676a59a 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/constants.ts +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/constants.ts @@ -5,8 +5,13 @@ import { ApiTooth } from "@eshg/dental-api"; +import { NavigateDirection } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/actions/navigate"; + import { ToothType } from "./types"; +export const MIN_TOOTH_INDEX = 0; +export const MAX_TOOTH_INDEX = 7; + /** * Defines a mapping from milk teeth to permanent teeth and vice versa */ @@ -139,3 +144,10 @@ export const OPTIONAL_TEETH = new Set<ApiTooth>([ "T47", "T48", ]); + +export const NAVIGATE_DIRECTIONS: Record<string, NavigateDirection> = { + ArrowUp: "UP", + ArrowDown: "DOWN", + ArrowLeft: "LEFT", + ArrowRight: "RIGHT", +}; diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/dentalExaminationStore.ts b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/dentalExaminationStore.ts index 08cd0e677b6225507e3a5be36d2e9898a65c8477..ea487e7f087a9d732cbee00ac4a630876192722f 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/dentalExaminationStore.ts +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/dentalExaminationStore.ts @@ -13,27 +13,35 @@ import { createStore } from "zustand"; import { addTooth, getToothDiagnoses, + removeTooth, setFocus, setMainResult, setSecondaryResult1, setSecondaryResult2, } from "./actions"; +import { NavigateDirection, navigate } from "./actions/navigate"; import { createSecondaryDentition } from "./factories"; -import { DentalExaminationView, Dentition, Focus, ToothContext } from "./types"; +import { + DentalExaminationView, + Dentition, + ElementContext, + ToothContext, +} from "./types"; export interface DentalExaminationState { currentView: DentalExaminationView; + currentFocus: ElementContext; dentition: Dentition; - focus: Focus; } export interface DentalExaminationActions { setView: (newView: DentalExaminationView) => void; + setFocus: (focus: ElementContext) => void; + navigate: (direction: NavigateDirection) => void; addTooth: ToothAction; removeTooth: ToothAction; toggleToothType: ToothAction; - setFocus: (focus: Focus) => void; setMainResult: SetToothResultAction; setSecondaryResult1: SetToothResultAction; @@ -62,7 +70,7 @@ export function initDentalExaminationStore( currentView: "UPPER_JAW", // TODO ISSUE-6584: distinguish between type of dentition dentition: createSecondaryDentition(toothDiagnoses), - focus: { + currentFocus: { toothContext: { quadrantNumber: "Q1", toothIndex: 0 }, field: "main", }, @@ -81,13 +89,15 @@ export function createDentalExaminationStore( })); }, removeTooth: (toothContext: ToothContext) => { - throw new Error("Not yet implemented"); + set((state) => ({ + dentition: removeTooth(toothContext, state.dentition), + })); }, toggleToothType: (toothContext: ToothContext) => { throw new Error("Not yet implemented"); }, - setFocus: (focus: Focus) => { - set(setFocus(focus)); + setFocus: (newFocus: ElementContext) => { + set(setFocus(newFocus)); }, setMainResult: (toothContext: ToothContext, newValue: string) => set((state) => ({ @@ -102,5 +112,6 @@ export function createDentalExaminationStore( dentition: setSecondaryResult2(toothContext, newValue, state.dentition), })), getToothDiagnoses: () => getToothDiagnoses(get().dentition), + navigate: (direction) => set((state) => navigate(direction, state)), })); } diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/types.ts b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/types.ts index 3e73437e5457b312ada020e87b1dc56d9a860c0d..7b9faf6f8cc66757c89b4da4283a20e500fe9414 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/types.ts +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/types.ts @@ -71,9 +71,18 @@ export interface ToothContext { toothIndex: number; } -export type FieldVariant = "main" | "secondary1" | "secondary2"; +export type ResultField = "main" | "secondary1" | "secondary2"; -export interface Focus { +export interface ElementContext { toothContext: ToothContext; - field: FieldVariant; + field?: ResultField; +} + +export function hasPreviousExaminationResult( + tooth: ToothWithDiagnosis, +): boolean { + return ( + tooth.previousResults.length > 0 && + tooth.previousResults[0] !== ApiMainResult.S + ); } diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/options.ts b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/options.ts index 72d91684bae485a769178e69f0d1be570daec62d..0aea68a037e04536733c3e093b5b0392070ec467 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/options.ts +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/options.ts @@ -3,11 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ApiProphylaxisType } from "@eshg/dental-api"; +import { ApiDentitionType, ApiProphylaxisType } from "@eshg/dental-api"; import { buildEnumOptions } from "@eshg/lib-portal/helpers/form"; -import { PROPHYLAXIS_TYPES } from "@/lib/businessModules/dental/features/prophylaxisSessions/translations"; -import { FLUORIDATION_VARNISH_TYPES } from "@/lib/businessModules/dental/features/prophylaxisSessions/translations"; +import { + DENTITION_TYPES, + FLUORIDATION_VARNISH_TYPES, + PROPHYLAXIS_TYPES, +} from "@/lib/businessModules/dental/features/prophylaxisSessions/translations"; export const PROPHYLAXIS_TYPE_OPTIONS = buildEnumOptions<ApiProphylaxisType>(PROPHYLAXIS_TYPES); @@ -15,3 +18,6 @@ export const PROPHYLAXIS_TYPE_OPTIONS = export const FLUORIDATION_VARNISH_OPTIONS = buildEnumOptions<string>( FLUORIDATION_VARNISH_TYPES, ); + +export const DENTITION_TYPE_OPTIONS = + buildEnumOptions<ApiDentitionType>(DENTITION_TYPES); diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/ParticipantExaminationBottomBar.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/ParticipantExaminationBottomBar.tsx index 80d00a63c2315fb22e4dc2ef2fda312c6850ad24..545bc6053fb8f7d098e0acd3d512bac824c3da00 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/ParticipantExaminationBottomBar.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/ParticipantExaminationBottomBar.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { BottomToolbar } from "@eshg/lib-employee-portal/components/toolbar/BottomToolbar"; import { KeyboardArrowLeftOutlined, KeyboardArrowRightOutlined, @@ -10,7 +11,7 @@ import { import { Button } from "@mui/joy"; import { isDefined } from "remeda"; -import { StickyBottomButtonBar } from "@/lib/shared/components/buttons/StickyBottomButtonBar"; +import { ButtonBar } from "@/lib/shared/components/buttons/ButtonBar"; interface ParticipantExaminationBottomBarProps { onPreviousParticipantClicked?: () => void; @@ -28,40 +29,42 @@ export function ParticipantExaminationBottomBar( } = props; return ( - <StickyBottomButtonBar - left={ - <> - {isDefined(onPreviousParticipantClicked) && ( + <BottomToolbar> + <ButtonBar + left={ + <> + {isDefined(onPreviousParticipantClicked) && ( + <Button + startDecorator={<KeyboardArrowLeftOutlined />} + variant="outlined" + onClick={props.onPreviousParticipantClicked} + > + Vorheriges Kind + </Button> + )} + <Button variant="plain" onClick={props.onOverviewClicked}> + Zur Übersicht + </Button> + </> + } + right={ + isDefined(onNextParticipantClicked) ? ( + <Button + endDecorator={<KeyboardArrowRightOutlined />} + onClick={onNextParticipantClicked} + > + Fertig & nächstes Kind + </Button> + ) : ( <Button - startDecorator={<KeyboardArrowLeftOutlined />} - variant="outlined" - onClick={props.onPreviousParticipantClicked} + endDecorator={<KeyboardArrowRightOutlined />} + onClick={onOverviewClicked} > - Vorheriges Kind + Fertig & zur Übersicht </Button> - )} - <Button variant="plain" onClick={props.onOverviewClicked}> - Zur Übersicht - </Button> - </> - } - right={ - isDefined(onNextParticipantClicked) ? ( - <Button - endDecorator={<KeyboardArrowRightOutlined />} - onClick={onNextParticipantClicked} - > - Fertig & nächstes Kind - </Button> - ) : ( - <Button - endDecorator={<KeyboardArrowRightOutlined />} - onClick={onOverviewClicked} - > - Fertig & zur Übersicht - </Button> - ) - } - /> + ) + } + /> + </BottomToolbar> ); } diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/ParticipantExaminationForm.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/ParticipantExaminationForm.tsx index 436a2659e9960c2e6e8a1c891719871fe1606a31..c0f9e1ddba1bdbaaad3a669ef3a48e7c50a496db 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/ParticipantExaminationForm.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/ParticipantExaminationForm.tsx @@ -5,14 +5,13 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; import { FormPlus } from "@eshg/lib-portal/components/form/FormPlus"; import { RequiresChildren } from "@eshg/lib-portal/types/react"; import { styled } from "@mui/joy"; import { FormikProps, FormikProvider } from "formik"; -import { ReactNode } from "react"; import { ExaminationFormValues } from "@/lib/businessModules/dental/features/examinations/ExaminationFormLayout"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; const FullHeightFormPlus = styled(FormPlus)({ display: "flex", @@ -22,7 +21,6 @@ const FullHeightFormPlus = styled(FormPlus)({ export interface ParticipantExaminationFormProps extends RequiresChildren { form: FormikProps<ExaminationFormValues>; - bottomBar: ReactNode; } export function ParticipantExaminationForm( @@ -31,10 +29,7 @@ export function ParticipantExaminationForm( return ( <FormikProvider value={props.form}> <FullHeightFormPlus> - <MainContentLayout fullViewportHeight> - {props.children} - </MainContentLayout> - {props.bottomBar} + <MainContentLayout>{props.children}</MainContentLayout> </FullHeightFormPlus> </FormikProvider> ); diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/ParticipantExaminationPage.tsx b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/ParticipantExaminationPage.tsx index 99ca0749691fa952bdeb512532190bd259553f82..bb73ab1446fff6da202db9f6a822c78f5da2afe2 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/ParticipantExaminationPage.tsx +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/ParticipantExaminationPage.tsx @@ -4,22 +4,22 @@ */ import { ChildExamination } from "@eshg/dental/api/models/ChildExamination"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { isDefined } from "remeda"; import { AdditionalInformationFormSection } from "@/lib/businessModules/dental/features/examinations/AdditionalInformationFormSection"; +import { ChildDetailsSection } from "@/lib/businessModules/dental/features/examinations/ChildDetailsSection"; import { ExaminationFormLayout } from "@/lib/businessModules/dental/features/examinations/ExaminationFormLayout"; import { NoteFormSection } from "@/lib/businessModules/dental/features/examinations/NoteFormSection"; import { DentalExaminationFormSection } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExamination/DentalExaminationFormSection"; -import { useDentalExaminationStore } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/DentalExaminationStoreProvider"; import { ParticipantExaminationBottomBar } from "@/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/ParticipantExaminationBottomBar"; import { ParticipantExaminationForm } from "@/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/ParticipantExaminationForm"; import { ParticipantExaminationToolbar } from "@/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/ParticipantExaminationToolbar"; import { useParticipantExaminationForm } from "@/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/useParticipantExaminationForm"; import { useParticipantNavigation } from "@/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/useParticipantNavigation"; import { useProphylaxisSessionStore } from "@/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/ProphylaxisSessionStoreProvider"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; interface ParticipantExaminationPageProps { participant: ChildExamination; @@ -33,6 +33,9 @@ export function ParticipantExaminationPage( const { participant, participantIndex, participantsLength } = props; const router = useRouter(); const prophylaxisSessionId = useProphylaxisSessionStore((state) => state.id); + const dateOfExamination = useProphylaxisSessionStore( + (state) => state.dateAndTime, + ); const isScreening = useProphylaxisSessionStore((state) => state.isScreening); const fluoridationVarnish = useProphylaxisSessionStore( (state) => state.fluoridationVarnish, @@ -40,22 +43,13 @@ export function ParticipantExaminationPage( const setExamination = useProphylaxisSessionStore( (state) => state.setExamination, ); - const getToothDiagnoses = useDentalExaminationStore( - (state) => state.getToothDiagnoses, - ); const [nextRoute, setNextRoute] = useState<string>(); const examinationForm = useParticipantExaminationForm({ initialValues: participant, onSubmit: (values) => { try { - const toothDiagnoses = getToothDiagnoses(); - const result = - values.result?.type === "screening" - ? { ...values.result, toothDiagnoses } - : values.result; - - setExamination(participant.examinationId, result, values.note); + setExamination(participant.examinationId, values.result, values.note); if (isDefined(nextRoute)) { router.push(nextRoute); } @@ -85,25 +79,35 @@ export function ParticipantExaminationPage( onBackClicked={examinationNavigation.gotoOverview} /> } + bottomToolbar={ + <ParticipantExaminationBottomBar + onPreviousParticipantClicked={ + examinationNavigation.gotoPreviousParticipant + } + onNextParticipantClicked={examinationNavigation.gotoNextParticipant} + onOverviewClicked={examinationNavigation.gotoOverview} + /> + } > - <ParticipantExaminationForm - form={examinationForm} - bottomBar={ - <ParticipantExaminationBottomBar - onPreviousParticipantClicked={ - examinationNavigation.gotoPreviousParticipant - } - onNextParticipantClicked={examinationNavigation.gotoNextParticipant} - onOverviewClicked={examinationNavigation.gotoOverview} - /> - } - > + <ParticipantExaminationForm form={examinationForm}> <ExaminationFormLayout + childInformation={ + <ChildDetailsSection + firstName={participant.firstName} + lastName={participant.lastName} + dateOfBirth={participant.dateOfBirth} + dateOfExamination={dateOfExamination} + groupName={participant.groupName} + allFluoridationConsents={participant.allFluoridationConsents} + /> + } additionalInformation={ <AdditionalInformationFormSection screening={isScreening} fluoridation={isDefined(fluoridationVarnish)} - fluoridationConsentGiven={participant.fluoridationConsentGiven} + fluoridationConsentGiven={ + participant.currentFluoridationConsent?.consented + } status={participant.status} /> } diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/useParticipantExaminationForm.ts b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/useParticipantExaminationForm.ts index b6df30289d4890336871f58f6501ef1d4a889f1f..60b4af9c754da2a80cd7f5946a14eed290bbc566 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/useParticipantExaminationForm.ts +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/participantExamination/useParticipantExaminationForm.ts @@ -3,12 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { ApiDentitionType, ApiTooth } from "@eshg/dental-api"; import { ExaminationResult, FluoridationExaminationResult, ScreeningExaminationResult, isEmptyExaminationResult, } from "@eshg/dental/api/models/ExaminationResult"; +import { ToothDiagnosis } from "@eshg/dental/api/models/ToothDiagnosis"; import { mapOptionalValue } from "@eshg/lib-portal/helpers/form"; import { useFormik } from "formik"; @@ -16,6 +18,7 @@ import { ExaminationFormValues, mapToExaminationFormValues, } from "@/lib/businessModules/dental/features/examinations/ExaminationFormLayout"; +import { useDentalExaminationStore } from "@/lib/businessModules/dental/features/prophylaxisSessions/dentalExaminationStore/DentalExaminationStoreProvider"; import { useProphylaxisSessionStore } from "@/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/ProphylaxisSessionStoreProvider"; interface ExaminationInputValues { @@ -39,6 +42,9 @@ export function useParticipantExaminationForm( const { initialValues, onSubmit } = params; const isScreening = useProphylaxisSessionStore((state) => state.isScreening); + const getToothDiagnoses = useDentalExaminationStore( + (state) => state.getToothDiagnoses, + ); return useFormik({ initialValues: mapToExaminationFormValues( @@ -46,7 +52,9 @@ export function useParticipantExaminationForm( initialValues.note, ), onSubmit: (formValues: ExaminationFormValues) => { - onSubmit(mapToExaminationValues(isScreening, formValues)); + onSubmit( + mapToExaminationValues(isScreening, formValues, getToothDiagnoses()), + ); }, enableReinitialize: true, }); @@ -55,9 +63,10 @@ export function useParticipantExaminationForm( function mapToExaminationValues( screening: boolean, formValues: ExaminationFormValues, + toothDiagnoses: Partial<Record<ApiTooth, ToothDiagnosis>>, ): ExaminationOutputValues { return { - result: mapToExaminationResult(screening, formValues), + result: mapToExaminationResult(screening, formValues, toothDiagnoses), note: mapOptionalValue(formValues.note), }; } @@ -65,6 +74,7 @@ function mapToExaminationValues( function mapToExaminationResult( screening: boolean, formValues: ExaminationFormValues, + toothDiagnoses: Partial<Record<ApiTooth, ToothDiagnosis>>, ): ExaminationResult | undefined { let result: FluoridationExaminationResult | ScreeningExaminationResult; if (screening) { @@ -74,7 +84,8 @@ function mapToExaminationResult( fluorideVarnishApplied: mapOptionalValue( formValues.fluorideVarnishApplied, ), - toothDiagnoses: {}, + dentitionType: ApiDentitionType.Mixed, + toothDiagnoses: toothDiagnoses, }; } else { // TODO: Remove when fluoridation only examination is handled without form diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/participantFilters.ts b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/participantFilters.ts index 774a0c4cc7c0ebf754bd4868e3097613ffdcde19..16a6d6d4d742bfe41581c6aa188aa2175b1e9ef9 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/participantFilters.ts +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/participantFilters.ts @@ -44,5 +44,5 @@ function matchesFluoridationConsent( } const requiresConsent = filter === "YES"; - return participant.fluoridationConsentGiven === requiresConsent; + return participant.currentFluoridationConsent?.consented === requiresConsent; } diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/participantSorting.ts b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/participantSorting.ts index 0e8ad45086e7706606b4d7c9c5430dd28cb992e2..55bca904037ff51e3d07cd7b0e2b83402a824434 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/participantSorting.ts +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/participantSorting.ts @@ -15,15 +15,21 @@ export interface ParticipantSorting { sortDirection: ParticipantSortDirection; } -export type ParticipantSortKey = keyof Omit< +type ParticipantSortAttributes = Omit< ChildExamination, - "childId" | "result" | "note" | "examinationId" | "examinationVersion" + | "childId" + | "result" + | "note" + | "examinationId" + | "examinationVersion" + | "allFluoridationConsents" >; +export type ParticipantSortKey = keyof ParticipantSortAttributes; export type ParticipantSortDirection = "asc" | "desc"; type ParticipantComparator = ( - a: ChildExamination, - b: ChildExamination, + a: ParticipantSortAttributes, + b: ParticipantSortAttributes, ) => number; export function sortParticipants( @@ -43,11 +49,14 @@ export function sortParticipants( } function compareMultiple( - ...comparators: ((a: ChildExamination, b: ChildExamination) => number)[] + ...comparators: (( + a: ParticipantSortAttributes, + b: ParticipantSortAttributes, + ) => number)[] ): ParticipantComparator { return function compareInOrder( - a: ChildExamination, - b: ChildExamination, + a: ParticipantSortAttributes, + b: ParticipantSortAttributes, ): number { for (const comparator of comparators) { const result = comparator(a, b); @@ -62,13 +71,13 @@ function compareBy( sortDirection: ParticipantSortDirection, ): ParticipantComparator { return function compareParticipant( - a: ChildExamination, - b: ChildExamination, + a: ParticipantSortAttributes, + b: ParticipantSortAttributes, ): number { switch (sortKey) { case "dateOfBirth": return b.dateOfBirth.getDate() - a.dateOfBirth.getDate(); - case "fluoridationConsentGiven": + case "currentFluoridationConsent": return compareFluoridation(a, b, sortDirection); case "gender": return compareGender(a, b, sortDirection); @@ -81,19 +90,19 @@ function compareBy( } function compareFluoridation( - a: ChildExamination, - b: ChildExamination, + a: ParticipantSortAttributes, + b: ParticipantSortAttributes, sortDirection: ParticipantSortDirection, ): number { - const aValue = displayBoolean(a.fluoridationConsentGiven); - const bValue = displayBoolean(b.fluoridationConsentGiven); + const aValue = displayBoolean(a.currentFluoridationConsent?.consented); + const bValue = displayBoolean(b.currentFluoridationConsent?.consented); return compareAndSortEmptyStringToEnd(aValue, bValue, sortDirection); } function compareGender( - a: ChildExamination, - b: ChildExamination, + a: ParticipantSortAttributes, + b: ParticipantSortAttributes, sortDirection: ParticipantSortDirection, ): number { const aValue = isDefined(a.gender) ? GENDER_VALUES[a.gender] : ""; @@ -102,7 +111,10 @@ function compareGender( return compareAndSortEmptyStringToEnd(aValue, bValue, sortDirection); } -function compareStatus(a: ChildExamination, b: ChildExamination): number { +function compareStatus( + a: ParticipantSortAttributes, + b: ParticipantSortAttributes, +): number { const aValue = EXAMINATION_STATUS[a.status]; const bValue = EXAMINATION_STATUS[b.status]; diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/useSyncOutgoingProphylaxisSessionChanges.ts b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/useSyncOutgoingProphylaxisSessionChanges.ts index 256452a443220ad29e1e5d51d656b6a6b44376df..7d46a095bba8e72e5d8072599249de6371223efb 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/useSyncOutgoingProphylaxisSessionChanges.ts +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/prophylaxisSessionStore/useSyncOutgoingProphylaxisSessionChanges.ts @@ -94,6 +94,7 @@ function mapScreeningResult( type: "ScreeningExaminationResult", fluorideVarnishApplied: screeningResult.fluorideVarnishApplied, oralHygieneStatus: screeningResult.oralHygieneStatus, + dentitionType: screeningResult.dentitionType, toothDiagnoses: Object.values(screeningResult.toothDiagnoses), }; } diff --git a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/translations.ts b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/translations.ts index 8047bd2d2f505e336a0cbbf0c17cc35b45670758..cff0bd1176e6e952fcad259f1aa41435303485d5 100644 --- a/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/translations.ts +++ b/employee-portal/src/lib/businessModules/dental/features/prophylaxisSessions/translations.ts @@ -3,7 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ApiFluoridationVarnish, ApiProphylaxisType } from "@eshg/dental-api"; +import { + ApiDentitionType, + ApiFluoridationVarnish, + ApiProphylaxisType, +} from "@eshg/dental-api"; import { EnumMap } from "@eshg/lib-portal/types/helpers"; export const PROPHYLAXIS_TYPES: EnumMap<ApiProphylaxisType> = { @@ -16,6 +20,12 @@ export const PROPHYLAXIS_TYPES: EnumMap<ApiProphylaxisType> = { [ApiProphylaxisType.P7]: "P7 (nur Unterrichtseinheit)", }; +export const DENTITION_TYPES: EnumMap<ApiDentitionType> = { + [ApiDentitionType.Primary]: "Milchgebiss", + [ApiDentitionType.Mixed]: "Wechselgebiss", + [ApiDentitionType.Secondary]: "Bleibendes Gebiss", +}; + export const FLUORIDATION_VARNISH_TYPES: EnumMap<ApiFluoridationVarnish> = { [ApiFluoridationVarnish.A]: "A", [ApiFluoridationVarnish.B]: "B", diff --git a/employee-portal/src/lib/businessModules/dental/shared/FluoridationConsentInformationSection.tsx b/employee-portal/src/lib/businessModules/dental/shared/FluoridationConsentInformationSection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9208ccadd23b9e1946f4848b5e6a866ad5fece2a --- /dev/null +++ b/employee-portal/src/lib/businessModules/dental/shared/FluoridationConsentInformationSection.tsx @@ -0,0 +1,110 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ApiFluoridationConsent } from "@eshg/dental-api"; +import { formatDate } from "@eshg/lib-portal/formatters/dateTime"; +import { Button, Stack, Typography } from "@mui/joy"; +import { isDefined } from "remeda"; + +import { FluoridationConsentTable } from "@/lib/businessModules/dental/shared/FluoridationConsentTable"; +import { ButtonBar } from "@/lib/shared/components/buttons/ButtonBar"; +import { DetailsItem } from "@/lib/shared/components/detailsSection/items/DetailsItem"; +import { DrawerProps } from "@/lib/shared/components/drawer/drawerContext"; +import { useSidebar } from "@/lib/shared/components/drawer/useSidebar"; +import { SidebarActions } from "@/lib/shared/components/sidebar/SidebarActions"; +import { SidebarContent } from "@/lib/shared/components/sidebar/SidebarContent"; +import { displayBoolean } from "@/lib/shared/helpers/booleans"; + +interface FluoridationConsentInformationSectionProps { + allFluoridationConsents: ApiFluoridationConsent[]; +} + +export function FluoridationConsentInformationSection( + props: FluoridationConsentInformationSectionProps, +) { + const fluoridationConsent = props.allFluoridationConsents[0]; + const fluoridationOverviewSidebar = useSidebar({ + component: (drawerProps) => ( + <FluoridationOverviewSidebar + allFluoridationConsents={props.allFluoridationConsents} + onClose={drawerProps.onClose} + /> + ), + }); + if (!isDefined(fluoridationConsent)) { + return ( + <DetailsItem + label="Einverständnis zur Fluoridierung" + value="Liegt nicht vor" + /> + ); + } + return ( + <> + <Stack + direction="row" + alignItems="center" + justifyContent="space-between" + flexWrap="wrap" + > + <Typography fontWeight={600}>Fluoridierung</Typography> + <Button onClick={fluoridationOverviewSidebar.open} variant="plain"> + <Typography component="span" color="primary"> + ( + </Typography> + <Typography component="u" color="primary"> + Übersicht + </Typography> + <Typography component="span" color="primary"> + ) + </Typography> + </Button> + </Stack> + <Stack direction="row" gap={3} flexWrap="wrap"> + <DetailsItem + label="Einverständnis" + value={displayBoolean(fluoridationConsent.consented)} + /> + <DetailsItem + label="Datum" + value={formatDate(fluoridationConsent.dateOfConsent)} + /> + <DetailsItem + label="Allergie" + value={displayBoolean(fluoridationConsent.hasAllergy)} + /> + </Stack> + </> + ); +} + +interface FluoridationOverviewSidebarProps extends DrawerProps { + allFluoridationConsents: ApiFluoridationConsent[]; +} +function FluoridationOverviewSidebar(props: FluoridationOverviewSidebarProps) { + return ( + <> + <SidebarContent title="Übersicht Einverständnis zur Fluoridierung"> + <FluoridationConsentTable + fluoridationConsent={props.allFluoridationConsents} + /> + </SidebarContent> + <SidebarActions> + <ButtonBar + right={[ + <Button + color="neutral" + variant="soft" + key="close" + onClick={() => props.onClose()} + > + Schließen + </Button>, + ]} + /> + </SidebarActions> + </> + ); +} diff --git a/employee-portal/src/lib/businessModules/dental/features/children/details/FluoridationConsentTable.tsx b/employee-portal/src/lib/businessModules/dental/shared/FluoridationConsentTable.tsx similarity index 77% rename from employee-portal/src/lib/businessModules/dental/features/children/details/FluoridationConsentTable.tsx rename to employee-portal/src/lib/businessModules/dental/shared/FluoridationConsentTable.tsx index 22dc891108f4db9e7f4150f4b41bcf3f95e76ec9..917f23bf21ee6b6b9f9dd41d5fcfa6c86f670e93 100644 --- a/employee-portal/src/lib/businessModules/dental/features/children/details/FluoridationConsentTable.tsx +++ b/employee-portal/src/lib/businessModules/dental/shared/FluoridationConsentTable.tsx @@ -10,7 +10,6 @@ import { formatDate } from "@eshg/lib-portal/formatters/dateTime"; import { createColumnHelper } from "@tanstack/react-table"; import { DataTable } from "@/lib/shared/components/table/DataTable"; -import { TablePage } from "@/lib/shared/components/table/TablePage"; import { TableSheet } from "@/lib/shared/components/table/TableSheet"; import { displayBoolean } from "@/lib/shared/helpers/booleans"; @@ -20,16 +19,13 @@ interface FluoridationConsentTableProps { export function FluoridationConsentTable(props: FluoridationConsentTableProps) { return ( - <TablePage sx={{ width: 650 }}> - <TableSheet> - <DataTable - data={props.fluoridationConsent} - columns={COLUMNS} - enableSortingRemoval={false} - minWidth={500} - /> - </TableSheet> - </TablePage> + <TableSheet> + <DataTable + data={props.fluoridationConsent} + columns={COLUMNS} + enableSortingRemoval={false} + /> + </TableSheet> ); } diff --git a/employee-portal/src/lib/businessModules/inspection/components/checklistDefinition/readOnly/ReadOnlyCLDPage.tsx b/employee-portal/src/lib/businessModules/inspection/components/checklistDefinition/readOnly/ReadOnlyCLDPage.tsx index be420a422031766eddba87ddf4b099492d062de8..8a068d0b4d0eadcbf5c93c8355a16ae8ad158311 100644 --- a/employee-portal/src/lib/businessModules/inspection/components/checklistDefinition/readOnly/ReadOnlyCLDPage.tsx +++ b/employee-portal/src/lib/businessModules/inspection/components/checklistDefinition/readOnly/ReadOnlyCLDPage.tsx @@ -4,12 +4,12 @@ */ import { ApiChecklistDefinitionVersion } from "@eshg/inspection-api"; +import { useLayoutConfig } from "@eshg/lib-employee-portal/contexts/layoutConfig"; +import { useHeaderHeights } from "@eshg/lib-employee-portal/hooks/useHeaderHeights"; import { InfoOutlined } from "@mui/icons-material"; import { Alert, Box } from "@mui/joy"; import { ReactNode } from "react"; -import { simpleToolbarHeight } from "@/lib/baseModule/components/layout/sizes"; -import { useHeaderHeights } from "@/lib/baseModule/components/layout/useHeaderHeights"; import { CLDInfoCard } from "@/lib/businessModules/inspection/components/checklistDefinition/readOnly/CLDInfoCard"; import { ReadOnlyCLDContent } from "@/lib/businessModules/inspection/components/checklistDefinition/readOnly/ReadOnlyCLDContent"; @@ -23,6 +23,8 @@ export function ReadOnlyCLDPage({ infoCard, }: Readonly<ReadOnlyCLDPageProps>) { const { headerHeightDesktop } = useHeaderHeights(); + const { simpleToolbarHeight } = useLayoutConfig(); + return ( <Box sx={{ diff --git a/employee-portal/src/lib/businessModules/inspection/components/facility/pending/NewFacilityButton.tsx b/employee-portal/src/lib/businessModules/inspection/components/facility/pending/NewFacilityButton.tsx index 977ffdbfa66563a95aef965e1dcb2e55c0c43446..b8e53a5e0e39eec12566b2e20a5f281fc9c1aef4 100644 --- a/employee-portal/src/lib/businessModules/inspection/components/facility/pending/NewFacilityButton.tsx +++ b/employee-portal/src/lib/businessModules/inspection/components/facility/pending/NewFacilityButton.tsx @@ -14,40 +14,41 @@ import { useSnackbar } from "@eshg/lib-portal/components/snackbar/SnackbarProvid import { Add } from "@mui/icons-material"; import { Button } from "@mui/joy"; import { useRouter } from "next/navigation"; -import { useState } from "react"; import { useAddInspectionFacility, useLinkBaseFacility, } from "@/lib/businessModules/inspection/api/mutations/facility"; import { routes } from "@/lib/businessModules/inspection/shared/routes"; -import { OverlayBoundary } from "@/lib/shared/components/boundaries/OverlayBoundary"; -import { FacilitySidebar } from "@/lib/shared/components/facilitySidebar/FacilitySidebar"; +import { + FacilitySidebar, + FacilitySidebarProps, +} from "@/lib/shared/components/facilitySidebar/FacilitySidebar"; import { DefaultFacilityFormValues } from "@/lib/shared/components/facilitySidebar/create/FacilityForm"; -import { useSidebarForm } from "@/lib/shared/hooks/useSidebarForm"; +import { + SidebarWithFormRefProps, + useSidebarWithFormRef, +} from "@/lib/shared/hooks/useSidebarWithFormRef"; export function NewFacilityButton() { + const facilitySidebar = useSidebarWithFormRef({ + component: ConfiguredFacilitySidebar, + }); + return ( - <OverlayBoundary> - <NewFacilityButtonWithinOverlay /> - </OverlayBoundary> + <Button onClick={() => facilitySidebar.open()} startDecorator={<Add />}> + Neue Erstbesichtigung anlegen + </Button> ); } -function NewFacilityButtonWithinOverlay() { - const [open, setOpen] = useState(false); +function ConfiguredFacilitySidebar(props: SidebarWithFormRefProps) { const router = useRouter(); const snackbar = useSnackbar(); const { mutateAsync: linkBaseFacility } = useLinkBaseFacility(); const { mutateAsync: addInspectionFacility } = useAddInspectionFacility(); - const { handleClose, closeSidebar, sidebarFormRef } = useSidebarForm({ - onClose: () => setOpen(false), - }); - function afterSave(addFacilityResponse: ApiInspAddFacilityResponse) { - closeSidebar(); - // If we get an inspection that is not in draft status, we should route to that inspection and not to the new inspection dialog. if (addFacilityResponse.procedureStatus !== ApiProcedureStatus.Draft) { router.push(routes.procedures.details(addFacilityResponse.procedureId)); @@ -96,21 +97,14 @@ function NewFacilityButtonWithinOverlay() { ); } - return ( - <> - <Button onClick={() => setOpen(true)} startDecorator={<Add />}> - Neue Erstbesichtigung anlegen - </Button> + const facilitySidebarProps: FacilitySidebarProps<DefaultFacilityFormValues> = + { + title: "Neue Erstbesichtigung anlegen", + submitLabel: "Anlegen", + onCreateNew: (values) => handleSubmit(values.createInputs), + onSelect: (values) => handleSelectFacility(values.facility), + ...props, + }; - <FacilitySidebar - title="Neue Erstbesichtigung anlegen" - submitLabel="Anlegen" - sidebarFormRef={sidebarFormRef} - onCreateNew={(values) => handleSubmit(values.createInputs)} - onSelect={(values) => handleSelectFacility(values.facility)} - onClose={handleClose} - open={open} - /> - </> - ); + return <FacilitySidebar {...facilitySidebarProps} />; } diff --git a/employee-portal/src/lib/businessModules/inspection/components/facility/search/FacilityWebSearchImportSidebar.tsx b/employee-portal/src/lib/businessModules/inspection/components/facility/search/FacilityWebSearchImportSidebar.tsx index 3c9704c77b59e799925c972a0e70a1df2c8b8223..b262f280da651c787670019c7571ec6389f116e7 100644 --- a/employee-portal/src/lib/businessModules/inspection/components/facility/search/FacilityWebSearchImportSidebar.tsx +++ b/employee-portal/src/lib/businessModules/inspection/components/facility/search/FacilityWebSearchImportSidebar.tsx @@ -20,47 +20,39 @@ import { useLinkBaseFacility, } from "@/lib/businessModules/inspection/api/mutations/facility"; import { routes } from "@/lib/businessModules/inspection/shared/routes"; -import { OverlayBoundary } from "@/lib/shared/components/boundaries/OverlayBoundary"; -import { EmbeddedFacilitySidebar } from "@/lib/shared/components/facilitySidebar/FacilitySidebar"; +import { FacilitySidebar } from "@/lib/shared/components/facilitySidebar/FacilitySidebar"; import { DefaultFacilityFormValues } from "@/lib/shared/components/facilitySidebar/create/FacilityForm"; import { BaseFacility } from "@/lib/shared/components/facilitySidebar/types"; -import { Sidebar } from "@/lib/shared/components/sidebar/Sidebar"; import { fullAddress } from "@/lib/shared/helpers/facilityUtils"; -import { useSidebarForm } from "@/lib/shared/hooks/useSidebarForm"; +import { + SidebarWithFormRefProps, + useSidebarWithFormRef, +} from "@/lib/shared/hooks/useSidebarWithFormRef"; type FacilityWebSearchImportSidebarProps = Readonly<{ - open: boolean; webSearchEntry: ApiWebSearchEntry | undefined; - onClose: () => void; -}>; +}> & + SidebarWithFormRefProps; -export function FacilityWebSearchImportSidebar( - props: FacilityWebSearchImportSidebarProps, -) { - return ( - <OverlayBoundary> - <FacilityWebSearchImportSidebarWithinBoundary {...props} /> - </OverlayBoundary> - ); +export function useFacilityWebSearchImportSidebar() { + return useSidebarWithFormRef({ + component: FacilityWebSearchImportSidebar, + }); } -function FacilityWebSearchImportSidebarWithinBoundary( +function FacilityWebSearchImportSidebar( props: FacilityWebSearchImportSidebarProps, ) { const snackbar = useSnackbar(); const router = useRouter(); - const { mutate: linkBaseFacility } = useLinkBaseFacility(); - const { mutate: addInspectionFacility } = useAddInspectionFacility(); - - const { handleClose, sidebarFormRef } = useSidebarForm({ - onClose: props.onClose, - }); + const { mutateAsync: linkBaseFacility } = useLinkBaseFacility(); + const { mutateAsync: addInspectionFacility } = useAddInspectionFacility(); function handleSaveFacility( facility: DefaultFacilityFormValues, webSearchEntryId: string, ) { - addInspectionFacility( + return addInspectionFacility( { facility, webSearchEntryId, @@ -69,7 +61,6 @@ function FacilityWebSearchImportSidebarWithinBoundary( onSuccess: afterSave, }, ); - return Promise.resolve(); } function afterSave(addFacilityResponse: ApiInspAddFacilityResponse) { @@ -81,7 +72,7 @@ function FacilityWebSearchImportSidebarWithinBoundary( facility: ApiGetReferenceFacilityResponse, webSearchEntryId: string, ) { - linkBaseFacility( + return linkBaseFacility( { facility, webSearchEntryId, @@ -103,41 +94,38 @@ function FacilityWebSearchImportSidebarWithinBoundary( }, }, ); - return Promise.resolve(); } const webSearchEntry = props.webSearchEntry; - return ( - <Sidebar open={props.open} onClose={handleClose}> - {isDefined(webSearchEntry) && ( - <EmbeddedFacilitySidebar - mode={"import"} - title={"Neuen Vorgang anlegen"} - searchResultHeaderComponent={ - <OsmFacilityCard - facility={createBaseFacilityFromWebSearchEntry(webSearchEntry)} - /> - } - initialSearchInputs={{ - name: webSearchEntry.name, - }} - onCreateNew={async (values) => { - await handleSaveFacility(values.createInputs, webSearchEntry.id); - }} - onSelect={async (values) => { - await handleSelectFacility(values.facility, webSearchEntry.id); - }} - sidebarFormRef={sidebarFormRef} - open={props.open} - onClose={handleClose} - getInitialCreateInputs={() => ({ - ...createBaseFacilityFromWebSearchEntry(webSearchEntry), - })} - /> - )} - </Sidebar> - ); + if (isDefined(webSearchEntry)) { + return ( + <FacilitySidebar + mode={"import"} + title={"Neuen Vorgang anlegen"} + searchResultHeaderComponent={ + <OsmFacilityCard + facility={createBaseFacilityFromWebSearchEntry(webSearchEntry)} + /> + } + initialSearchInputs={{ + name: webSearchEntry.name, + }} + onCreateNew={async (values) => { + await handleSaveFacility(values.createInputs, webSearchEntry.id); + }} + onSelect={async (values) => { + await handleSelectFacility(values.facility, webSearchEntry.id); + }} + formRef={props.formRef} + onClose={props.onClose} + getInitialCreateInputs={() => ({ + ...createBaseFacilityFromWebSearchEntry(webSearchEntry), + })} + /> + ); + } + return <></>; } function createBaseFacilityFromWebSearchEntry( diff --git a/employee-portal/src/lib/businessModules/inspection/components/facility/search/results/FacilityWebSearchResultsTable.tsx b/employee-portal/src/lib/businessModules/inspection/components/facility/search/results/FacilityWebSearchResultsTable.tsx index 48286530fdfb8794b5f2ec1c44dfe25fe879fa9b..c756855ac336848c374da62b7fd0f0c58feca997 100644 --- a/employee-portal/src/lib/businessModules/inspection/components/facility/search/results/FacilityWebSearchResultsTable.tsx +++ b/employee-portal/src/lib/businessModules/inspection/components/facility/search/results/FacilityWebSearchResultsTable.tsx @@ -16,14 +16,13 @@ import { useSnackbar } from "@eshg/lib-portal/components/snackbar/SnackbarProvid import AddIcon from "@mui/icons-material/Add"; import { Button, Chip, Stack, Typography } from "@mui/joy"; import ChipDelete from "@mui/joy/ChipDelete"; -import { useState } from "react"; import { useDeleteWebSearchQuery, useSaveWebSearchQuery, useUpdateWebSearchEntry, } from "@/lib/businessModules/inspection/api/mutations/webSearch"; -import { FacilityWebSearchImportSidebar } from "@/lib/businessModules/inspection/components/facility/search/FacilityWebSearchImportSidebar"; +import { useFacilityWebSearchImportSidebar } from "@/lib/businessModules/inspection/components/facility/search/FacilityWebSearchImportSidebar"; import { ignoredNames, webSearchStatusNames, @@ -46,11 +45,6 @@ import { createFacilitySearchResultSubRowColumns, } from "./columns"; -interface SidebarState { - open: boolean; - webSearchEntry?: ApiWebSearchEntry; -} - export function FacilityWebSearchResultsTable( props: Readonly<{ webSearch: ApiWebSearch; @@ -60,6 +54,7 @@ export function FacilityWebSearchResultsTable( }>, ) { const { mutateAsync: updateWebSearchEntry } = useUpdateWebSearchEntry(); + const facilityWebSearchImportSidebar = useFacilityWebSearchImportSidebar(); const tableControl = useTableControl({ serverSideSorting: true, @@ -72,12 +67,10 @@ export function FacilityWebSearchResultsTable( const subRowColumns = createFacilitySearchResultSubRowColumns(); - const [sidebarState, setSidebarState] = useState<SidebarState>({ - open: false, - }); - function addFacility(entry: ApiWebSearchEntry) { - setSidebarState({ open: true, webSearchEntry: entry }); + facilityWebSearchImportSidebar.open({ + webSearchEntry: entry, + }); } async function changeIgnored(entry: ApiWebSearchEntry, newValue: boolean) { @@ -121,12 +114,6 @@ export function FacilityWebSearchResultsTable( /> </TableSheet> </TablePage> - - <FacilityWebSearchImportSidebar - open={sidebarState.open} - webSearchEntry={sidebarState.webSearchEntry} - onClose={() => setSidebarState({ open: false })} - /> </> ); } diff --git a/employee-portal/src/lib/businessModules/inspection/components/inbox/InspectionInboxProcedureCreateSidebar.tsx b/employee-portal/src/lib/businessModules/inspection/components/inbox/InspectionInboxProcedureCreateSidebar.tsx index 9834c75cadb8d6ec3af2fa65b280e307c88f54fb..57f95930c48e3e200bbb3a18f33e742818055559 100644 --- a/employee-portal/src/lib/businessModules/inspection/components/inbox/InspectionInboxProcedureCreateSidebar.tsx +++ b/employee-portal/src/lib/businessModules/inspection/components/inbox/InspectionInboxProcedureCreateSidebar.tsx @@ -19,7 +19,7 @@ import { } from "@/lib/businessModules/inspection/api/mutations/facility"; import { useFetchInboxProcedure } from "@/lib/businessModules/inspection/api/queries/inboxProcedures"; import { routes } from "@/lib/businessModules/inspection/shared/routes"; -import { EmbeddedFacilitySidebar } from "@/lib/shared/components/facilitySidebar/FacilitySidebar"; +import { FacilitySidebar } from "@/lib/shared/components/facilitySidebar/FacilitySidebar"; import { DefaultFacilityFormValues } from "@/lib/shared/components/facilitySidebar/create/FacilityForm"; import { FacilitySearchFormValues } from "@/lib/shared/components/facilitySidebar/search/FacilitySearchForm"; import { BaseAddressFormInputs } from "@/lib/shared/components/form/address/helpers"; @@ -39,11 +39,11 @@ export function InspectionInboxProcedureCreateSidebar({ const router = useRouter(); const snackbar = useSnackbar(); - const { mutate: linkBaseFacility } = useLinkBaseFacility(); - const { mutate: addInspectionFacility } = useAddInspectionFacility(); + const { mutateAsync: linkBaseFacility } = useLinkBaseFacility(); + const { mutateAsync: addInspectionFacility } = useAddInspectionFacility(); function handleSaveFacility(facility: DefaultFacilityFormValues) { - addInspectionFacility( + return addInspectionFacility( { facility, inboxProcedureId }, { onSuccess: ({ procedureId }) => { @@ -52,11 +52,10 @@ export function InspectionInboxProcedureCreateSidebar({ }, }, ); - return Promise.resolve(); } function handleSelectFacility(facility: ApiGetReferenceFacilityResponse) { - linkBaseFacility( + return linkBaseFacility( { facility, inboxProcedureId }, { onSuccess: ({ inspectionId, procedureStatus, isNew }) => { @@ -75,7 +74,6 @@ export function InspectionInboxProcedureCreateSidebar({ }, }, ); - return Promise.resolve(); } const initialSearchInputs = inboxProcedure.contactDetails.facilityName @@ -85,7 +83,7 @@ export function InspectionInboxProcedureCreateSidebar({ : undefined; return ( - <EmbeddedFacilitySidebar + <FacilitySidebar mode="default" title="Neuen Vorgang anlegen" searchResultHeaderComponent={false} @@ -96,8 +94,7 @@ export function InspectionInboxProcedureCreateSidebar({ onSelect={async (values) => { await handleSelectFacility(values.facility); }} - sidebarFormRef={formRef} - open={true} + formRef={formRef} onClose={onClose} getInitialCreateInputs={(searchInputs?: FacilitySearchFormValues) => ({ ...createBaseFacilityFromInboxProcedure(inboxProcedure, searchInputs), diff --git a/employee-portal/src/lib/businessModules/inspection/components/inspection/planning/InspectionTabPlanning.tsx b/employee-portal/src/lib/businessModules/inspection/components/inspection/planning/InspectionTabPlanning.tsx index 73659201d68d6714d5366150392a560d771e0a0a..2c7a61a9097d964e0336d5e75ca3312fb3722ae6 100644 --- a/employee-portal/src/lib/businessModules/inspection/components/inspection/planning/InspectionTabPlanning.tsx +++ b/employee-portal/src/lib/businessModules/inspection/components/inspection/planning/InspectionTabPlanning.tsx @@ -8,12 +8,12 @@ import { ApiInspectionAvailableCLDVersionsResponse, ApiInspectionPhase, } from "@eshg/inspection-api"; +import { useHeaderHeights } from "@eshg/lib-employee-portal/hooks/useHeaderHeights"; import { useWindowDimensions } from "@eshg/lib-portal/hooks/useWindowDimension"; import { Box, useTheme } from "@mui/joy"; import { useSuspenseQueries } from "@tanstack/react-query"; import { useUserApi } from "@/lib/baseModule/api/clients"; -import { useHeaderHeights } from "@/lib/baseModule/components/layout/useHeaderHeights"; import { useInspectionApi } from "@/lib/businessModules/inspection/api/clients"; import { getAvailableCLDVsQuery, diff --git a/employee-portal/src/lib/businessModules/inspection/components/inspection/reportresult/InspectionTabReportResult.tsx b/employee-portal/src/lib/businessModules/inspection/components/inspection/reportresult/InspectionTabReportResult.tsx index 12a935742869ab649843269126b38433c7720407..d38857d791c1f7f74c51671f8f18bf85d08d6a2c 100644 --- a/employee-portal/src/lib/businessModules/inspection/components/inspection/reportresult/InspectionTabReportResult.tsx +++ b/employee-portal/src/lib/businessModules/inspection/components/inspection/reportresult/InspectionTabReportResult.tsx @@ -5,6 +5,7 @@ "use client"; +import { BottomToolbar } from "@eshg/lib-employee-portal/components/toolbar/BottomToolbar"; import { Grid } from "@mui/joy"; import { useConfiguration } from "@/lib/businessModules/inspection/api/clients"; @@ -12,8 +13,9 @@ import { useGetInspectionAndLoadEditor } from "@/lib/businessModules/inspection/ import { InspectionResultSidePanel } from "@/lib/businessModules/inspection/components/inspection/reportresult/InspectionResultSidePanel"; import { ReportApprovalButtons } from "@/lib/businessModules/inspection/components/inspection/reportresult/ReportApprovalButtons"; import { ReportDownloadButtons } from "@/lib/businessModules/inspection/components/inspection/reportresult/ReportDownloadButtons"; -import { StickyBottomButtonBar } from "@/lib/shared/components/buttons/StickyBottomButtonBar"; +import { ButtonBar } from "@/lib/shared/components/buttons/ButtonBar"; import { ContentDisplay } from "@/lib/shared/components/contentEditor/ContentDisplay"; +import { StickyBottomBox } from "@/lib/shared/components/layout/StickyBottomBox"; interface InspectionTabReportResultProps { inspectionId: string; @@ -63,10 +65,14 @@ export function InspectionTabReportResult({ </Grid> </Grid> - <StickyBottomButtonBar - left={<ReportDownloadButtons reportId={editorData.id} />} - right={<ReportApprovalButtons inspection={inspection} />} - /> + <StickyBottomBox> + <BottomToolbar> + <ButtonBar + left={<ReportDownloadButtons reportId={editorData.id} />} + right={<ReportApprovalButtons inspection={inspection} />} + /> + </BottomToolbar> + </StickyBottomBox> </> ); } diff --git a/employee-portal/src/lib/businessModules/inspection/components/inspection/reportresult/editor/InspectionReportEditor.tsx b/employee-portal/src/lib/businessModules/inspection/components/inspection/reportresult/editor/InspectionReportEditor.tsx index ea069b652a6b2398835867a23b103890b73ee420..354b71c1f8345f0ef7251c1511ff2fcefad484e7 100644 --- a/employee-portal/src/lib/businessModules/inspection/components/inspection/reportresult/editor/InspectionReportEditor.tsx +++ b/employee-portal/src/lib/businessModules/inspection/components/inspection/reportresult/editor/InspectionReportEditor.tsx @@ -6,6 +6,7 @@ "use client"; import { ApiEditorBodyElementsInner } from "@eshg/inspection-api"; +import { BottomToolbar } from "@eshg/lib-employee-portal/components/toolbar/BottomToolbar"; import { useSuspenseQueries } from "@tanstack/react-query"; import { v4 as uuidv4 } from "uuid"; @@ -19,12 +20,13 @@ import { loadEditorQuery, } from "@/lib/businessModules/inspection/api/queries/inspectionReport"; import { ReportDownloadButtons } from "@/lib/businessModules/inspection/components/inspection/reportresult/ReportDownloadButtons"; -import { StickyBottomButtonBar } from "@/lib/shared/components/buttons/StickyBottomButtonBar"; +import { ButtonBar } from "@/lib/shared/components/buttons/ButtonBar"; import { ContentEditor } from "@/lib/shared/components/contentEditor/ContentEditor"; import { PaletteItem, PaletteItemType, } from "@/lib/shared/components/contentEditor/types"; +import { StickyBottomBox } from "@/lib/shared/components/layout/StickyBottomBox"; export function InspectionReportEditor({ reportId, @@ -78,9 +80,11 @@ export function InspectionReportEditor({ onAddItem={onAddItem} imagesBasePath={`${basePath}/checklists/file/`} /> - <StickyBottomButtonBar - left={<ReportDownloadButtons reportId={reportId} />} - /> + <StickyBottomBox> + <BottomToolbar> + <ButtonBar left={<ReportDownloadButtons reportId={reportId} />} /> + </BottomToolbar> + </StickyBottomBox> </> ); } diff --git a/employee-portal/src/lib/businessModules/inspection/shared/sideNavigationItem.tsx b/employee-portal/src/lib/businessModules/inspection/shared/sideNavigationItem.tsx index 4b00d9137a0b727ff0ee45dab6adc63a6f7cf431..e7523f5093552d5adeea1a24cac94633be0fd3e9 100644 --- a/employee-portal/src/lib/businessModules/inspection/shared/sideNavigationItem.tsx +++ b/employee-portal/src/lib/businessModules/inspection/shared/sideNavigationItem.tsx @@ -5,12 +5,11 @@ import { ApiUserRole } from "@eshg/base-api"; import { hasUserRole } from "@eshg/lib-employee-portal/helpers/accessControl"; -import { OtherHousesOutlined } from "@mui/icons-material"; - import { SideNavigationSubItem, UseSideNavigationItemsResult, -} from "@/lib/baseModule/components/layout/sideNavigation/types"; +} from "@eshg/lib-employee-portal/types/sideNavigation"; +import { OtherHousesOutlined } from "@mui/icons-material"; import { routes } from "./routes"; diff --git a/employee-portal/src/lib/businessModules/measlesProtection/layout/MeaslesProtectionLayout.tsx b/employee-portal/src/lib/businessModules/measlesProtection/layout/MeaslesProtectionLayout.tsx index 69cb5f6e827b1d9d93acf676b1337505505aafa8..0a36c4a95590b69592efd2818871f468e3c3aa5c 100644 --- a/employee-portal/src/lib/businessModules/measlesProtection/layout/MeaslesProtectionLayout.tsx +++ b/employee-portal/src/lib/businessModules/measlesProtection/layout/MeaslesProtectionLayout.tsx @@ -3,12 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { + Toolbar, + ToolbarProps, +} from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { RequiresChildren } from "@eshg/lib-portal/types/react"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar, ToolbarProps } from "@/lib/shared/components/layout/Toolbar"; - type MeaslesProtectionLayoutProps = RequiresChildren & ToolbarProps; export function MeaslesProtectionLayout({ children, diff --git a/employee-portal/src/lib/businessModules/measlesProtection/layout/MeaslesProtectionProcedureLayout.tsx b/employee-portal/src/lib/businessModules/measlesProtection/layout/MeaslesProtectionProcedureLayout.tsx index 498e3e8c416a8ee396d75b5ae2cb727fcffa4b38..b7bdb628fa36f14ebc34a6c281833d00f15183b8 100644 --- a/employee-portal/src/lib/businessModules/measlesProtection/layout/MeaslesProtectionProcedureLayout.tsx +++ b/employee-portal/src/lib/businessModules/measlesProtection/layout/MeaslesProtectionProcedureLayout.tsx @@ -6,14 +6,14 @@ "use client"; import { ApiUserRole } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; import { PropsWithChildren } from "react"; import { useProcedureQuery } from "@/lib/businessModules/measlesProtection/api/queries/procedures"; import { CaseStatusSelect } from "@/lib/businessModules/measlesProtection/components/procedures/procedureDetails/CaseStatusSelect"; import { routes } from "@/lib/businessModules/measlesProtection/shared/routes"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; import { PersonToolbarHeader } from "@/lib/shared/components/layout/PersonToolbarHeader"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; import { TabNavigationItem } from "@/lib/shared/components/tabNavigation/types"; import { TabNavigationToolbar } from "@/lib/shared/components/tabNavigationToolbar/TabNavigationToolbar"; import { useHasUserRoleCheck } from "@/lib/shared/hooks/useAccessControl"; diff --git a/employee-portal/src/lib/businessModules/measlesProtection/shared/constants.ts b/employee-portal/src/lib/businessModules/measlesProtection/shared/constants.ts index 5fda9932939334eb4def0e62c3d3c5a6b0095431..964fbe1075ddc16af4afe84d817108631d778414 100644 --- a/employee-portal/src/lib/businessModules/measlesProtection/shared/constants.ts +++ b/employee-portal/src/lib/businessModules/measlesProtection/shared/constants.ts @@ -37,5 +37,6 @@ export const APPOINTMENT_TYPES: EnumMap<ApiAppointmentType> = { [ApiAppointmentType.HivStiConsultation]: "HIV-STI-Beratung", [ApiAppointmentType.SexWork]: "Sexarbeit", [ApiAppointmentType.ResultsReview]: "Ergebnisbesprechung", - [ApiAppointmentType.OfficialMedicalService]: "Amtsärtzlicher Dienst", + [ApiAppointmentType.OfficialMedicalServiceShort]: "Kleine Untersuchung", + [ApiAppointmentType.OfficialMedicalServiceLong]: "Große Untersuchung", }; diff --git a/employee-portal/src/lib/businessModules/measlesProtection/shared/sideNavigationItem.tsx b/employee-portal/src/lib/businessModules/measlesProtection/shared/sideNavigationItem.tsx index a846e01355748b80a836dcc0aaf02511e940aeca..6e428a5a612e78a679e4400db23428b4ee8ba0cc 100644 --- a/employee-portal/src/lib/businessModules/measlesProtection/shared/sideNavigationItem.tsx +++ b/employee-portal/src/lib/businessModules/measlesProtection/shared/sideNavigationItem.tsx @@ -5,13 +5,13 @@ import { ApiBaseFeature, ApiUserRole } from "@eshg/base-api"; import { hasUserRole } from "@eshg/lib-employee-portal/helpers/accessControl"; -import { HubOutlined } from "@mui/icons-material"; - -import { useIsNewFeatureEnabled } from "@/lib/baseModule/api/queries/feature"; import { SideNavigationSubItem, UseSideNavigationItemsResult, -} from "@/lib/baseModule/components/layout/sideNavigation/types"; +} from "@eshg/lib-employee-portal/types/sideNavigation"; +import { HubOutlined } from "@mui/icons-material"; + +import { useIsNewFeatureEnabled } from "@/lib/baseModule/api/queries/feature"; import { routes } from "./routes"; diff --git a/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/EmployeeInformationForm.tsx b/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/EmployeeInformationForm.tsx index 762eaa039442854b2758adb25582e6fd3c96781e..7f3f3088746f214d36111d2e1fdde88ae37a405f 100644 --- a/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/EmployeeInformationForm.tsx +++ b/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/EmployeeInformationForm.tsx @@ -33,7 +33,9 @@ export function EmployeeInformationForm(props: NestedFormProps) { return ( <> <Grid xxs={12}> - <Typography level="h3">Angaben zu Mitarbeiter:innen</Typography> + <Typography level="h3" component="h2"> + Angaben zu Mitarbeiter:innen + </Typography> </Grid> <Grid xxs={12}> <BooleanRadioField diff --git a/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/GeneralInformationForm.tsx b/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/GeneralInformationForm.tsx index 7e79bc3f0eff8230e229bb93c78d9d440eb4165d..d395ada4db795b7a60d73dcbdf2df6b8895a9195 100644 --- a/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/GeneralInformationForm.tsx +++ b/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/GeneralInformationForm.tsx @@ -23,7 +23,9 @@ export function GeneralInformationForm(props: NestedFormProps) { return ( <> <Grid xxs={12}> - <Typography level="h3">Allgemeine Angaben</Typography> + <Typography level="h3" component="h2"> + Allgemeine Angaben + </Typography> </Grid> <Grid xxs={6}> <SelectField diff --git a/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/OccupationalInformationForm.tsx b/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/OccupationalInformationForm.tsx index 44002de3325596e6c619c52c75d66c1f333c75ed..cd0bacf20cfc4a056285891742ad1449d8facbbd 100644 --- a/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/OccupationalInformationForm.tsx +++ b/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/OccupationalInformationForm.tsx @@ -40,7 +40,9 @@ export function OccupationalInformationForm(props: NestedFormProps) { return ( <> <Grid xxs={12}> - <Typography level="h3">Berufsangaben</Typography> + <Typography level="h3" component="h2"> + Berufsangaben + </Typography> </Grid> <Grid xxs={6}> <SelectField diff --git a/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/PersonalInformationForm.tsx b/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/PersonalInformationForm.tsx index 46d11d347069405e62342957c8d40788da2f14cd..767c9aa5a09523b65febd7ef1ad01b0682f9344d 100644 --- a/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/PersonalInformationForm.tsx +++ b/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/PersonalInformationForm.tsx @@ -41,7 +41,9 @@ export function PersonalInformationForm(props: NestedFormProps) { return ( <> <Grid xxs={12}> - <Typography level="h3">Angaben zur antragstellenden Person</Typography> + <Typography level="h3" component="h2"> + Angaben zur antragstellenden Person + </Typography> </Grid> <Grid xxs={6}> diff --git a/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/PracticeInformationForm.tsx b/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/PracticeInformationForm.tsx index cd14dfd490ece1422128d888427e20567c9f1b6f..7bb1fe757d5a5896f9728aa5b1fd7b57502d1571 100644 --- a/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/PracticeInformationForm.tsx +++ b/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/PracticeInformationForm.tsx @@ -40,7 +40,9 @@ export function PracticeInformationForm(props: PracticeInformationFormProps) { return ( <> <Grid xxs={12}> - <Typography level="h3">Praxis-/Tätigkeitsangaben</Typography> + <Typography level="h3" component="h2"> + Praxis-/Tätigkeitsangaben + </Typography> </Grid> {props.forceProprietaryPractice ? ( <> diff --git a/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/ProfessionalismInformationForm.tsx b/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/ProfessionalismInformationForm.tsx index e17e3a10257e6712b9ce54658b0bbc1a7391b35b..ae613ebfcd394600ef75eb6ae1b19a9267932d39 100644 --- a/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/ProfessionalismInformationForm.tsx +++ b/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/ProfessionalismInformationForm.tsx @@ -27,7 +27,9 @@ export function ProfessionalismInformationForm(props: NestedFormProps) { return ( <> <Grid xxs={12}> - <Typography level="h3">Angaben zur Berufsausübung</Typography> + <Typography level="h3" component="h2"> + Angaben zur Berufsausübung + </Typography> </Grid> <Grid xxs={12}> <RadioGroupField diff --git a/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/RequiredDocumentsForm.tsx b/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/RequiredDocumentsForm.tsx index 48cdb4c24d4ff48f5f25abab5ba5b7411c54e7a3..6b6df76c3bf5e57fe3d78d06d62a4185c4b59480 100644 --- a/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/RequiredDocumentsForm.tsx +++ b/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/RequiredDocumentsForm.tsx @@ -46,7 +46,9 @@ export function RequiredDocumentsForm(props: RequiredDocumentsFormProps) { return ( <> <Grid xxs={12}> - <Typography level="h3">Erforderliche Unterlagen</Typography> + <Typography level="h3" component="h2"> + Erforderliche Unterlagen + </Typography> </Grid> {props.enableOptionalDocuments && ( diff --git a/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/WrittenConfirmationForm.tsx b/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/WrittenConfirmationForm.tsx index 26369dbb6c3564d93369d30ea3d4fd33b2f72fe8..ac43b35ab7ab63f0ed844274adab9e92b49bb6b2 100644 --- a/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/WrittenConfirmationForm.tsx +++ b/employee-portal/src/lib/businessModules/medicalRegistry/components/procedures/create/WrittenConfirmationForm.tsx @@ -16,7 +16,9 @@ export function WrittenConfirmationForm(props: NestedFormProps) { return ( <> - <Typography level="h3">Bescheinigung</Typography> + <Typography level="h3" component="h2"> + Bescheinigung + </Typography> <BooleanRadioField name={fieldName("requestForWrittenConfirmation")} label="Es soll eine schriftliche Meldebestätigung per Post versendet werden." diff --git a/employee-portal/src/lib/businessModules/medicalRegistry/shared/sideNavigationItem.tsx b/employee-portal/src/lib/businessModules/medicalRegistry/shared/sideNavigationItem.tsx index ea6b4c089049bde349e36f93aee3b8bf737f2523..3154295ead757df0565494c838973a41d046ab7d 100644 --- a/employee-portal/src/lib/businessModules/medicalRegistry/shared/sideNavigationItem.tsx +++ b/employee-portal/src/lib/businessModules/medicalRegistry/shared/sideNavigationItem.tsx @@ -5,10 +5,9 @@ import { ApiUserRole } from "@eshg/base-api"; import { hasUserRole } from "@eshg/lib-employee-portal/helpers/accessControl"; +import { UseSideNavigationItemsResult } from "@eshg/lib-employee-portal/types/sideNavigation"; import { MedicalServicesOutlined } from "@mui/icons-material"; -import { UseSideNavigationItemsResult } from "@/lib/baseModule/components/layout/sideNavigation/types"; - import { routes } from "./routes"; export function useSideNavigationItems( diff --git a/employee-portal/src/lib/businessModules/officialMedicalService/api/queries/appointmentBlocksApi.ts b/employee-portal/src/lib/businessModules/officialMedicalService/api/queries/appointmentBlocksApi.ts index 6d751c8fddc80987d958f59e9e170c19849c66ec..78de516de7b8be923751262af73dc29c4c324a10 100644 --- a/employee-portal/src/lib/businessModules/officialMedicalService/api/queries/appointmentBlocksApi.ts +++ b/employee-portal/src/lib/businessModules/officialMedicalService/api/queries/appointmentBlocksApi.ts @@ -53,13 +53,20 @@ export function useValidateDailyAppointmentBlocksForGroup( }); } -export function useGetFreeAppointmentsQuery(physicianId?: string) { +export function useGetFreeAppointmentsQuery( + appointmentType: ApiAppointmentType, + physicianId?: string, +) { const appointmentApi = useAppointmentBlockApi(); return useSuspenseQuery({ - queryKey: appointmentBlockApiQueryKey(["getFreeAppointments", physicianId]), + queryKey: appointmentBlockApiQueryKey([ + "getFreeAppointments", + appointmentType, + physicianId, + ]), queryFn: () => appointmentApi.getFreeAppointments( - ApiAppointmentType.OfficialMedicalService, + appointmentType, undefined, physicianId, ), diff --git a/employee-portal/src/lib/businessModules/officialMedicalService/components/appointmentBlocks/constants.ts b/employee-portal/src/lib/businessModules/officialMedicalService/components/appointmentBlocks/constants.ts index 6a3e57ade656c09b27ec607c4488b5957f70eb62..0d7791948c66fe4a8324b98d183feca49ead548c 100644 --- a/employee-portal/src/lib/businessModules/officialMedicalService/components/appointmentBlocks/constants.ts +++ b/employee-portal/src/lib/businessModules/officialMedicalService/components/appointmentBlocks/constants.ts @@ -17,5 +17,6 @@ export const APPOINTMENT_TYPES: EnumMap<ApiAppointmentType> = { [ApiAppointmentType.HivStiConsultation]: "HIV-STI-Beratung", [ApiAppointmentType.SexWork]: "Sexarbeit", [ApiAppointmentType.ResultsReview]: "Ergebnisbesprechung", - [ApiAppointmentType.OfficialMedicalService]: "Amtsärtzlicher Dienst", + [ApiAppointmentType.OfficialMedicalServiceShort]: "Kleine Untersuchung", + [ApiAppointmentType.OfficialMedicalServiceLong]: "Große Untersuchung", }; diff --git a/employee-portal/src/lib/businessModules/officialMedicalService/components/appointmentBlocks/options.ts b/employee-portal/src/lib/businessModules/officialMedicalService/components/appointmentBlocks/options.ts index e9a5c1b45816b606da7a465fb5a3dd45a9027836..913e38b6d09a8ffad1434be4a016e7004199d48d 100644 --- a/employee-portal/src/lib/businessModules/officialMedicalService/components/appointmentBlocks/options.ts +++ b/employee-portal/src/lib/businessModules/officialMedicalService/components/appointmentBlocks/options.ts @@ -10,7 +10,8 @@ import { APPOINTMENT_TYPES } from "@/lib/businessModules/officialMedicalService/ import { WAITING_STATUS_VALUES } from "@/lib/businessModules/officialMedicalService/shared/translations"; const SUPPORTED_APPOINTMENT_TYPES: string[] = [ - ApiAppointmentType.OfficialMedicalService, + ApiAppointmentType.OfficialMedicalServiceShort, + ApiAppointmentType.OfficialMedicalServiceLong, ]; export const APPOINTMENT_TYPE_OPTIONS = buildEnumOptions( diff --git a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/AddFacility.tsx b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/AddFacility.tsx index d89e00459a2d389afda4b45979836c10c4875556..08d8120e312e4b704f3002ac9fe0e04277fdd399 100644 --- a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/AddFacility.tsx +++ b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/AddFacility.tsx @@ -6,71 +6,72 @@ import { ApiGetReferenceFacilityResponse } from "@eshg/base-api"; import { InfoOutlined } from "@mui/icons-material"; import { Alert, Stack } from "@mui/joy"; -import { useState } from "react"; import { usePostFacility } from "@/lib/businessModules/officialMedicalService/api/mutations/employeeOmsProcedureApi"; import { mapToDefaultFacilityFormValues } from "@/lib/businessModules/officialMedicalService/shared/helpers"; -import { FacilitySidebar } from "@/lib/shared/components/facilitySidebar/FacilitySidebar"; +import { + FacilitySidebar, + FacilitySidebarProps, +} from "@/lib/shared/components/facilitySidebar/FacilitySidebar"; import { DefaultFacilityFormValues } from "@/lib/shared/components/facilitySidebar/create/FacilityForm"; +import { FacilitySearchFormValues } from "@/lib/shared/components/facilitySidebar/search/FacilitySearchForm"; import { InfoTileAddButton } from "@/lib/shared/components/infoTile/InfoTileAddButton"; -import { useSidebarForm } from "@/lib/shared/hooks/useSidebarForm"; +import { + SidebarWithFormRefProps, + useSidebarWithFormRef, +} from "@/lib/shared/hooks/useSidebarWithFormRef"; export function AddFacility({ id }: Readonly<{ id: string }>) { - const [sidebarOpen, setSidebarOpen] = useState(false); - const postFacility = usePostFacility(); - const { handleClose, closeSidebar, sidebarFormRef } = useSidebarForm({ - onClose: () => setSidebarOpen(false), + const facilitySidebar = useSidebarWithFormRef({ + component: ConfiguredFacilitySidebar, }); - async function handleSubmit(facility: DefaultFacilityFormValues) { - await postFacility.mutateAsync( - { - id: id, - facility: facility, - }, - { - onSuccess: () => { - closeSidebar(); - }, - }, - ); - } - - async function handleSelectFacility( - facility: ApiGetReferenceFacilityResponse, - ) { - await postFacility.mutateAsync( - { - id: id, - facility: mapToDefaultFacilityFormValues(facility), - }, - { - onSuccess: () => { - closeSidebar(); - }, - }, - ); - } - return ( <> <Stack gap={2} sx={{ pt: 1 }}> <Alert color={"warning"} startDecorator={<InfoOutlined />}> Um einen Vorgang anzulegen, muss ein Auftraggeber ergänzt werden. </Alert> - <InfoTileAddButton onClick={() => setSidebarOpen(true)}> + <InfoTileAddButton onClick={() => facilitySidebar.open({ id })}> Hinzufügen </InfoTileAddButton> </Stack> - <FacilitySidebar - title="Auftraggeber hinzufügen" - submitLabel="Speichern" - sidebarFormRef={sidebarFormRef} - onCreateNew={(values) => handleSubmit(values.createInputs)} - onSelect={(values) => handleSelectFacility(values.facility)} - onClose={handleClose} - open={sidebarOpen} - /> </> ); } + +function ConfiguredFacilitySidebar( + props: SidebarWithFormRefProps & + Readonly<{ + id: string; + }>, +) { + const postFacility = usePostFacility(); + + async function handleSubmit(facility: DefaultFacilityFormValues) { + await postFacility.mutateAsync({ + id: props.id, + facility: facility, + }); + } + + async function handleSelectFacility( + facility: ApiGetReferenceFacilityResponse, + ) { + await postFacility.mutateAsync({ + id: props.id, + facility: mapToDefaultFacilityFormValues(facility), + }); + } + + const facilitySidebarProps: FacilitySidebarProps<FacilitySearchFormValues> = { + title: "Auftraggeber hinzufügen", + submitLabel: "Speichern", + onCreateNew: (values) => handleSubmit(values.createInputs), + onSelect: (values) => handleSelectFacility(values.facility), + formRef: props.formRef, + onClose: props.onClose, + }; + + return <FacilitySidebar {...facilitySidebarProps} />; +} diff --git a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/AppointmentSidebar.tsx b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/AppointmentSidebar.tsx index 2b2d44ae213bf57c77543875638f98d9e8515e9c..e33214ce907ee356404378ab37051faf56c94b68 100644 --- a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/AppointmentSidebar.tsx +++ b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/AppointmentSidebar.tsx @@ -28,13 +28,21 @@ import { import { Sheet, Stack, Typography } from "@mui/joy"; import { addMinutes, isEqual } from "date-fns"; import { Formik, FormikHelpers, useFormikContext } from "formik"; -import { ReactNode, useMemo, useReducer, useState } from "react"; +import { + Dispatch, + ReactNode, + SetStateAction, + useEffect, + useMemo, + useReducer, + useState, +} from "react"; import { clamp, isEmpty, prop, sortBy } from "remeda"; import { useBookAppointment } from "@/lib/businessModules/officialMedicalService/api/mutations/appointmentApi"; import { usePostAppointment } from "@/lib/businessModules/officialMedicalService/api/mutations/employeeOmsProcedureApi"; import { useGetFreeAppointmentsQuery } from "@/lib/businessModules/officialMedicalService/api/queries/appointmentBlocksApi"; -import { APPOINTMENT_TYPES } from "@/lib/businessModules/schoolEntry/features/procedures/translations"; +import { APPOINTMENT_TYPE_OPTIONS } from "@/lib/businessModules/officialMedicalService/components/appointmentBlocks/options"; import { DetailsItem } from "@/lib/shared/components/detailsSection/items/DetailsItem"; import { MultiFormButtonBar } from "@/lib/shared/components/form/MultiFormButtonBar"; import { SidebarForm } from "@/lib/shared/components/form/SidebarForm"; @@ -65,6 +73,7 @@ interface AppointmentFormValues { export function useCreateAppointmentSidebar( procedureId: string, + appointmentType: ApiAppointmentType, physician?: ApiUser, ) { const { mutateAsync: createAppointment } = usePostAppointment(); @@ -91,6 +100,7 @@ export function useCreateAppointmentSidebar( return EmbeddedAppointmentSidebar({ onSave: handleSave, allowSelfBooking: true, + appointmentType: appointmentType, physician, ...props, }); @@ -98,7 +108,10 @@ export function useCreateAppointmentSidebar( }); } -export function useAppointmentSidebar(physician?: ApiUser) { +export function useAppointmentSidebar( + appointmentType: ApiAppointmentType, + physician?: ApiUser, +) { const { mutateAsync: bookAppointment } = useBookAppointment(); return useSidebarWithFormRef({ @@ -120,6 +133,7 @@ export function useAppointmentSidebar(physician?: ApiUser) { return EmbeddedAppointmentSidebar({ onSave: handleSave, allowSelfBooking: false, + appointmentType: appointmentType, physician, ...props, }); @@ -135,6 +149,7 @@ interface AppointmentSidebarProps extends SidebarWithFormRefProps { onSave: (values: AppointmentFormValues) => Promise<void>; appointment?: ApiOmsAppointment; allowSelfBooking: boolean; + appointmentType: ApiAppointmentType; physician?: ApiUser; } @@ -151,16 +166,10 @@ interface SidebarStep { fields: (props: Readonly<FieldsProps>) => ReactNode; } -function getAppointmentTypeOptions() { - return [ - { - value: ApiAppointmentType.OfficialMedicalService, - label: APPOINTMENT_TYPES[ApiAppointmentType.OfficialMedicalService], - }, - ]; -} - -function getSteps(editingExistingAppointment: boolean): SidebarStep[] { +function getSteps( + editingExistingAppointment: boolean, + setCurrentAppointmentType: Dispatch<SetStateAction<ApiAppointmentType>>, +): SidebarStep[] { return editingExistingAppointment ? [ { @@ -174,14 +183,15 @@ function getSteps(editingExistingAppointment: boolean): SidebarStep[] { title: "Termin buchen", subTitle: "Schritt 1 von 2", fields: () => ( - <> - <SelectField - name="appointmentType" - label="Terminart" - required="Bitte eine Terminart auswählen" - options={getAppointmentTypeOptions()} - /> - </> + <SelectField + name="appointmentType" + label="Terminart" + required="Bitte eine Terminart auswählen" + options={APPOINTMENT_TYPE_OPTIONS} + onChange={(value) => + setCurrentAppointmentType(value as ApiAppointmentType) + } + /> ), }, { @@ -199,13 +209,17 @@ function EmbeddedAppointmentSidebar({ appointment, allowSelfBooking, physician, + appointmentType, }: Readonly<AppointmentSidebarProps>) { + const [currentAppointmentType, setCurrentAppointmentType] = + useState(appointmentType); const { appointments, initialValues } = useAppointments( + currentAppointmentType, appointment, physician?.userId, ); - const steps = getSteps(!!appointment); + const steps = getSteps(!!appointment, setCurrentAppointmentType); const lastStepIndex = steps.length - 1; const [stepIndex, changeToStep] = useReducer( (_index: number, newIndex: number) => @@ -355,16 +369,21 @@ function BookingForm({ } function useAppointments( + appointmentType: ApiAppointmentType, appointment?: ApiOmsAppointment, physicianId?: string, ): { appointments: ApiAppointment[]; initialValues: AppointmentFormValues; } { - const { data } = useGetFreeAppointmentsQuery(physicianId); + const { data } = useGetFreeAppointmentsQuery(appointmentType, physicianId); const [appointments, setAppointments] = useState(data.appointments); + useEffect(() => { + setAppointments(data.appointments); + }, [data.appointments]); + return useMemo(() => { let blockAppointment: ApiAppointment | undefined = undefined; if ( @@ -398,7 +417,7 @@ function useAppointments( return { appointments, initialValues: { - appointmentType: ApiAppointmentType.OfficialMedicalService, + appointmentType: appointmentType, bookingType: appointment?.bookingType ?? ApiBookingType.AppointmentBlock, appointment: blockAppointment, @@ -406,7 +425,7 @@ function useAppointments( duration: duration ?? 30, }, }; - }, [appointments, appointment]); + }, [appointments, appointment, appointmentType]); } function AppointmentBlockForm({ diff --git a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/AppointmentsPanel.tsx b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/AppointmentsPanel.tsx index 891d97f2cc1109b0986536c83c380ba683061af8..a68e9a17bb839a2aa352ba044fcd1a3d0eb59295 100644 --- a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/AppointmentsPanel.tsx +++ b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/AppointmentsPanel.tsx @@ -3,12 +3,18 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ApiEmployeeOmsProcedureDetails } from "@eshg/official-medical-service-api"; +import { + ApiAppointmentType, + ApiEmployeeOmsProcedureDetails, +} from "@eshg/official-medical-service-api"; import { Button } from "@mui/joy"; import { useCreateAppointmentSidebar } from "@/lib/businessModules/officialMedicalService/components/procedures/details/AppointmentSidebar"; import { AppointmentsTable } from "@/lib/businessModules/officialMedicalService/components/procedures/details/AppointmentsTable"; -import { isProcedureFinalized } from "@/lib/businessModules/officialMedicalService/shared/helpers"; +import { + isProcedureFinalized, + procedureHasOpenAppointments, +} from "@/lib/businessModules/officialMedicalService/shared/helpers"; import { CalendarAddDay } from "@/lib/shared/components/icons/CalendarAddDay"; import { InfoTile } from "@/lib/shared/components/infoTile/InfoTile"; @@ -19,6 +25,8 @@ export function AppointmentsPanel({ }>) { const { open: openSidebar } = useCreateAppointmentSidebar( procedure.id, + procedure.concern?.appointmentType ?? + ApiAppointmentType.OfficialMedicalServiceShort, procedure.physician, ); @@ -28,7 +36,8 @@ export function AppointmentsPanel({ name="appointments" data-testid="appointments" footer={ - !isProcedureFinalized(procedure) && ( + !isProcedureFinalized(procedure) && + !procedureHasOpenAppointments(procedure) && ( <Button variant="plain" sx={{ justifyContent: "start", width: "fit-content" }} diff --git a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/AppointmentsTable.tsx b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/AppointmentsTable.tsx index 4bdcd78de4bd67eeb866bcbf8e8d8ed65f945c25..c9b70b031ffca9788a98d36b53780a13aa58870b 100644 --- a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/AppointmentsTable.tsx +++ b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/AppointmentsTable.tsx @@ -7,6 +7,7 @@ import { formatDateTime } from "@eshg/lib-portal/formatters/dateTime"; import { EnumMap } from "@eshg/lib-portal/types/helpers"; import { ApiAppointmentState, + ApiAppointmentType, ApiBookingState, ApiEmployeeOmsProcedureDetails, ApiOmsAppointment, @@ -217,6 +218,8 @@ export function AppointmentsTable({ procedure: ApiEmployeeOmsProcedureDetails; }>) { const { open: openBookingSidebar } = useAppointmentSidebar( + procedure.concern?.appointmentType ?? + ApiAppointmentType.OfficialMedicalServiceShort, procedure.physician, ); const { openConfirmationDialog } = useConfirmationDialog(); diff --git a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/ConcernSidebar.tsx b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/ConcernSidebar.tsx index 0c432c8d2cc13ec7ab87ad286ce54b8765100edb..91eed72df9c8b4993aa4e26daaf2700ea9b755ba 100644 --- a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/ConcernSidebar.tsx +++ b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/ConcernSidebar.tsx @@ -41,6 +41,8 @@ export function useConcernSidebar() { }); } +const ALL_CATEGORIES_KEY = "ALL_CATEGORIES"; + export function ConcernSidebar({ onClose, procedure, @@ -52,7 +54,7 @@ export function ConcernSidebar({ const initialValues: ConcernFormType = { category: procedure.concern ? getCategoryKeyFromConcern(procedure.concern) - : null, + : ALL_CATEGORIES_KEY, concern: procedure.concern ? getConcernKeyFromConcern(procedure.concern) : null, @@ -71,7 +73,9 @@ export function ConcernSidebar({ const categoryMap: Map<string, ApiConcernCategoryConfig> = allConcernsResponse.categories.reduce((map, category) => { - map.set(getCategoryKeyFromCategoryConfig(category), category); + for (const concern of category.concerns) { + map.set(getConcernKeyFromConcernConfig(concern), category); + } return map; }, new Map<string, ApiConcernCategoryConfig>()); @@ -84,8 +88,8 @@ export function ConcernSidebar({ concern: { ...concern, version: procedure.concern?.version ?? 0, - categoryNameDe: categoryMap.get(values.category!)!.nameDe, - categoryNameEn: categoryMap.get(values.category!)!.nameEn, + categoryNameDe: categoryMap.get(values.concern)!.nameDe, + categoryNameEn: categoryMap.get(values.concern)!.nameEn, }, }, { @@ -145,7 +149,9 @@ function CategoryField({ function ConcernField({ allConcernsResponse, -}: Readonly<{ allConcernsResponse: ApiGetConcernsResponse }>) { +}: Readonly<{ + allConcernsResponse: ApiGetConcernsResponse; +}>) { const { values: { category }, } = useFormikContext<ConcernFormType>(); @@ -170,7 +176,9 @@ function optionsFromConcernsResponse( ): SelectOption<string>[] { return concernsResponse.categories .filter( - (category) => categoryKey === getCategoryKeyFromCategoryConfig(category), + (category) => + categoryKey === ALL_CATEGORIES_KEY || + categoryKey === getCategoryKeyFromCategoryConfig(category), ) .flatMap((category) => category.concerns.map((concern) => ({ @@ -183,10 +191,16 @@ function optionsFromConcernsResponse( function categoryOptionsFromConcernsResponse( concernsResponse: ApiGetConcernsResponse, ) { - return concernsResponse.categories.map((category) => ({ - value: getCategoryKeyFromCategoryConfig(category), - label: category.nameDe, - })); + return [ + { + value: ALL_CATEGORIES_KEY, + label: "Alle Kategorien", + }, + ...concernsResponse.categories.map((category) => ({ + value: getCategoryKeyFromCategoryConfig(category), + label: category.nameDe, + })), + ]; } function getCategoryKeyFromCategoryConfig( diff --git a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/documents/Columns.tsx b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/documents/Columns.tsx index f10a3a390d1466daadbe4507b03bde405a81fa72..977864785249f034d66e4b6e9ffe6ae7a2c9b93e 100644 --- a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/documents/Columns.tsx +++ b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/documents/Columns.tsx @@ -71,25 +71,18 @@ export function Columns({ // }, // enableSorting: true, // }), - // ToDo: missing attribute in BE "Hochgeladen von"; for now fixed value is displayed - // columnHelper.accessor("??", { - // header: "Hochgeladen von", - // cell: (props) => { - // return ( - // <Chip color={props.getValue() ? "warning" : "primary"} size="md"> - // {props.getValue() ? "Extern" : "Intern"} - // </Chip> - // ); - // }, - // enableSorting: true, - // }), - columnHelper.display({ + columnHelper.accessor("uploadedBy", { header: "Hochgeladen von", - cell: () => { + cell: (props) => { return ( - <Chip color="primary" size="md"> - Intern - </Chip> + props.getValue() && ( + <Chip + color={props.getValue() === "INTERN" ? "primary" : "warning"} + size="md" + > + {props.getValue() === "INTERN" ? "Intern" : "Extern"} + </Chip> + ) ); }, enableSorting: true, diff --git a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/documents/DocumentFormContent.tsx b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/documents/DocumentFormContent.tsx index e927662c90336201f0a4c5125f452674bb97ec5b..24f62c86d71dae4933ad66d40796f96a5b69cc94 100644 --- a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/documents/DocumentFormContent.tsx +++ b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/documents/DocumentFormContent.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { InputField } from "@eshg/lib-portal/components/formFields/InputField"; import { ApiDocument, ApiDocumentStatus, @@ -17,6 +18,7 @@ import { Stack, Typography, } from "@mui/joy"; +import { useField } from "formik"; import { ReactNode } from "react"; import { isEmpty } from "remeda"; @@ -34,6 +36,15 @@ export function DocumentFormContent(props: { onEditNote?: () => void; isProcedureFinalized: boolean; }) { + const [{ value: files }] = useField<File[]>("files"); + + const canAddFiles = + (props.document.documentStatus === ApiDocumentStatus.Missing || + props.document.documentStatus === ApiDocumentStatus.Rejected) && + !props.isProcedureFinalized; + + const showNoteField = !isEmpty(files) && canAddFiles; + return ( <SidebarContent title={props.title}> <Stack rowGap={3}> @@ -58,9 +69,11 @@ export function DocumentFormContent(props: { <ChipItem label="Hochgeladen von" color={ - props.document.uploadInCitizenPortal ? "warning" : "primary" + props.document.uploadedBy === "EXTERN" ? "warning" : "primary" + } + value={ + props.document.uploadedBy === "EXTERN" ? "Extern" : "Intern" } - value={props.document.uploadInCitizenPortal ? "Extern" : "Intern"} /> )} <ChipItem @@ -104,34 +117,39 @@ export function DocumentFormContent(props: { )} <FilesSection name="files" - canAdd={ - (props.document.documentStatus === ApiDocumentStatus.Missing || - props.document.documentStatus === ApiDocumentStatus.Rejected) && - !props.isProcedureFinalized - } + canAdd={canAddFiles} withInitialField={false} addLabel="Datei hinzufügen" files={props.document.files} /> - <Stack - direction="row" - gap={2} - justifyContent="space-between" - alignItems="start" - data-testid="noteSection" - > - <DetailsItem - label="Stichwörter" - value={!isEmpty(props.document.note) ? props.document.note : "-"} - slotProps={{ value: { pt: 1 } }} - /> - {!isEmpty(props.document.files) && !props.isProcedureFinalized && ( - <EditButton - aria-label="Stichwörter bearbeiten" - onClick={props.onEditNote} + {showNoteField ? ( + <Box data-testid="noteSection"> + <InputField name="note" label="Stichwörter" /> + </Box> + ) : ( + <Stack + direction="row" + gap={2} + justifyContent="space-between" + alignItems="start" + data-testid="noteSection" + > + <DetailsItem + label="Stichwörter" + value={ + !isEmpty(props.document.note) ? props.document.note : "-" + } + slotProps={{ value: { pt: 1 } }} /> - )} - </Stack> + {!isEmpty(props.document.files) && + !props.isProcedureFinalized && ( + <EditButton + aria-label="Stichwörter bearbeiten" + onClick={props.onEditNote} + /> + )} + </Stack> + )} </Stack> <Divider orientation="horizontal" /> diff --git a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/documents/DocumentSidebar.tsx b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/documents/DocumentSidebar.tsx index bba40c5c6fff873572a765f2f597e821e68ef89e..061170ad25037048de9dc17b02828a8afebd1894 100644 --- a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/documents/DocumentSidebar.tsx +++ b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/documents/DocumentSidebar.tsx @@ -73,6 +73,7 @@ function DocumentSidebar({ const request: PatchCompleteDocumentFileUploadRequest = { id: document.id, files: values.files as Blob[], + note: values.note, }; await patchCompleteDocumentFileUpload.mutateAsync(request, { diff --git a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/documents/FilesSection.tsx b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/documents/FilesSection.tsx index f56d35281d5198b67426a32e0159d11ea3587e87..06e772be0a8035a7183d069bf24091ce84e0c624 100644 --- a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/documents/FilesSection.tsx +++ b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/details/documents/FilesSection.tsx @@ -8,8 +8,12 @@ import { FormAddMoreButton } from "@eshg/lib-portal/components/form/FormAddMoreB import { FileType } from "@eshg/lib-portal/components/formFields/file/FileType"; import { ApiFileType } from "@eshg/lib-procedures-api"; import { ApiOmsFile } from "@eshg/official-medical-service-api"; -import { Delete, FileDownloadOutlined } from "@mui/icons-material"; -import { Stack } from "@mui/joy"; +import { + Delete, + FileDownloadOutlined, + Remove as RemoveIcon, +} from "@mui/icons-material"; +import { Button, Stack } from "@mui/joy"; import { useFormikContext } from "formik"; import { isDefined } from "remeda"; @@ -43,6 +47,8 @@ export function FilesSection(props: Readonly<FilesSectionProps>) { omsFileApi.getDownloadFileRaw({ fileId }), ); + const accept = [FileType.Pdf, FileType.Jpeg, FileType.Png]; + return ( <Stack gap={2} data-testid="files"> <Stack gap={1}> @@ -117,25 +123,48 @@ export function FilesSection(props: Readonly<FilesSectionProps>) { label="Datei hochladen (PDF, JPG oder PNG)" name="files" placeholder="Auswählen" - accept={[FileType.Pdf, FileType.Jpeg, FileType.Png]} + accept={accept} onChange={async (value) => { await setFieldTouched("files", true, false); - await setFieldValue( - props.name, - [...values.files!, value], - false, - ); - toggleActive(); + + // Only add this file if it is a valid file type + if (accept.some((a) => a.mimeType === value?.type)) { + await setFieldValue( + props.name, + [...values.files!, value], + false, + ); + // Only remove the upload card if the file was valid and added, otherwise it should stay and show the error + toggleActive(); + } else { + // We still need to set this value, but without the new file + await setFieldValue(props.name, [...values.files!], false); + } }} /> )} - <FormAddMoreButton - onClick={() => { - toggleActive(); - }} - > - {props.addLabel} - </FormAddMoreButton> + {active ? ( + <Button + color={"primary"} + variant={"plain"} + size={"sm"} + sx={{ justifyContent: "flex-start" }} + startDecorator={<RemoveIcon />} + onClick={() => { + toggleActive(); + }} + > + Hinzufügen abbrechen + </Button> + ) : ( + <FormAddMoreButton + onClick={() => { + toggleActive(); + }} + > + {props.addLabel} + </FormAddMoreButton> + )} </> )} </Stack> diff --git a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/overview/CreateProcedure.tsx b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/overview/CreateProcedure.tsx index d947e0a5ee035781f22259bd93337aeade74404e..9ce2b593c822260764eade10ff3c80d0848d3f69 100644 --- a/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/overview/CreateProcedure.tsx +++ b/employee-portal/src/lib/businessModules/officialMedicalService/components/procedures/overview/CreateProcedure.tsx @@ -10,7 +10,6 @@ import { ApiPostEmployeeOmsProcedureRequest } from "@eshg/official-medical-servi import { Add } from "@mui/icons-material"; import { Button } from "@mui/joy"; import { useRouter } from "next/navigation"; -import { useRef, useState } from "react"; import { usePostEmployeeProcedure } from "@/lib/businessModules/officialMedicalService/api/mutations/employeeOmsProcedureApi"; import { @@ -18,18 +17,31 @@ import { mapToCreateProcedureRequest, } from "@/lib/businessModules/officialMedicalService/shared/helpers"; import { routes } from "@/lib/businessModules/officialMedicalService/shared/routes"; -import { SidebarFormHandle } from "@/lib/shared/components/form/SidebarForm"; -import { PersonSidebar } from "@/lib/shared/components/personSidebar/PersonSidebar"; +import { + PersonSidebar, + PersonSidebarProps, +} from "@/lib/shared/components/personSidebar/PersonSidebar"; import { DefaultPersonFormValues } from "@/lib/shared/components/personSidebar/form/DefaultPersonForm"; -import { Sidebar } from "@/lib/shared/components/sidebar/Sidebar"; -import { useConfirmationDialog } from "@/lib/shared/hooks/useConfirmationDialog"; +import { + SidebarWithFormRefProps, + useSidebarWithFormRef, +} from "@/lib/shared/hooks/useSidebarWithFormRef"; export function CreateProcedure() { + const personSidebar = useSidebarWithFormRef({ + component: ConfiguredPersonSidebar, + }); + + return ( + <Button startDecorator={<Add />} onClick={() => personSidebar.open()}> + Neuen Vorgang anlegen + </Button> + ); +} + +function ConfiguredPersonSidebar(props: SidebarWithFormRefProps) { const router = useRouter(); const postEmployeeProcedure = usePostEmployeeProcedure(); - const [sidebarOpen, setSidebarOpen] = useState(false); - const sidebarFormRef = useRef<SidebarFormHandle>(null); - const { openCancelDialog } = useConfirmationDialog(); async function createProcedureWithNewPerson(person: DefaultPersonFormValues) { const request: ApiPostEmployeeOmsProcedureRequest = @@ -58,48 +70,18 @@ export function CreateProcedure() { }); } - function openSidebar() { - setSidebarOpen(true); - } - - function closeSidebar() { - setSidebarOpen(false); - } + const personSidebarProps: PersonSidebarProps = { + onSelect: async (values) => { + await createProcedureWithExistingPerson(values.person); + }, + onCreate: async (values) => { + await createProcedureWithNewPerson(values.createInputs); + }, + title: "Vorgang anlegen", + submitLabel: "Vorgang anlegen", + addressRequired: true, + ...props, + }; - function handleClose() { - if (sidebarFormRef.current?.dirty) { - openCancelDialog({ - onConfirm: closeSidebar, - }); - } else { - closeSidebar(); - } - } - - return ( - <> - <Button startDecorator={<Add />} onClick={() => openSidebar()}> - Neuen Vorgang anlegen - </Button> - <Sidebar open={sidebarOpen} onClose={handleClose}> - <PersonSidebar - onCancel={handleClose} - onSelect={async (values) => { - await createProcedureWithExistingPerson(values.person); - closeSidebar(); - return Promise.resolve(); - }} - onCreate={async (values) => { - await createProcedureWithNewPerson(values.createInputs); - closeSidebar(); - return Promise.resolve(); - }} - sidebarFormRef={sidebarFormRef} - title={"Vorgang anlegen"} - submitLabel={"Vorgang anlegen"} - addressRequired - /> - </Sidebar> - </> - ); + return <PersonSidebar {...personSidebarProps} />; } diff --git a/employee-portal/src/lib/businessModules/officialMedicalService/shared/helpers.ts b/employee-portal/src/lib/businessModules/officialMedicalService/shared/helpers.ts index 4a56e3e422efa107ed47d947718a5c83628f3990..c9e2735f5ab6689d74824d839c5fae4f50e4d85b 100644 --- a/employee-portal/src/lib/businessModules/officialMedicalService/shared/helpers.ts +++ b/employee-portal/src/lib/businessModules/officialMedicalService/shared/helpers.ts @@ -9,7 +9,9 @@ import { } from "@eshg/base-api"; import { ApiAffectedPerson, + ApiAppointmentState, ApiFacility, + ApiOmsAppointment, ApiPatchAffectedPersonRequest, ApiPatchEmployeeOmsProcedureFacilityRequest, ApiPostEmployeeOmsProcedureRequest, @@ -130,3 +132,11 @@ export function isProcedureOpenOrInProgress(procedure: { ]; return openOrInProgressStates.includes(procedure.status); } + +export function procedureHasOpenAppointments(procedure: { + appointments: ApiOmsAppointment[]; +}): boolean { + return procedure.appointments.some( + (appointment) => appointment.appointmentState === ApiAppointmentState.Open, + ); +} diff --git a/employee-portal/src/lib/businessModules/officialMedicalService/shared/sideNavigationItem.tsx b/employee-portal/src/lib/businessModules/officialMedicalService/shared/sideNavigationItem.tsx index 98746ba2d72d67c86d17202a1a43f4f5a489d4a6..8755227c3d836f3b465cb46b99e7baf8f02c084c 100644 --- a/employee-portal/src/lib/businessModules/officialMedicalService/shared/sideNavigationItem.tsx +++ b/employee-portal/src/lib/businessModules/officialMedicalService/shared/sideNavigationItem.tsx @@ -5,12 +5,12 @@ import { ApiUserRole } from "@eshg/base-api"; import { hasUserRole } from "@eshg/lib-employee-portal/helpers/accessControl"; -import { isPlainObject } from "remeda"; - import { SideNavigationItem, UseSideNavigationItemsResult, -} from "@/lib/baseModule/components/layout/sideNavigation/types"; +} from "@eshg/lib-employee-portal/types/sideNavigation"; +import { isPlainObject } from "remeda"; + import { StethoscopeIcon } from "@/lib/businessModules/officialMedicalService/components/icons/StethoscopeIcon"; import { routes } from "@/lib/businessModules/officialMedicalService/shared/routes"; diff --git a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/new/CreateProcedureSidebar.tsx b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/new/CreateProcedureSidebar.tsx index e388bcd62c3b84b967df97c328f454f9ac87fe27..f91e2587bc25ab730c34f438934d5ba034a0264b 100644 --- a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/new/CreateProcedureSidebar.tsx +++ b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/new/CreateProcedureSidebar.tsx @@ -13,10 +13,10 @@ import { ApiCreateProcedureRequest, ApiSchoolEntryProcedureType, } from "@eshg/school-entry-api"; +import { ApiProcedureDetails } from "@eshg/school-entry-api"; import { Add } from "@mui/icons-material"; import { Button } from "@mui/joy"; import { useRouter } from "next/navigation"; -import { useRef, useState } from "react"; import { useSchoolEntryApi } from "@/lib/businessModules/schoolEntry/api/clients"; import { useCreateProcedure } from "@/lib/businessModules/schoolEntry/api/mutations/schoolEntryApi"; @@ -25,8 +25,11 @@ import { ProcedureCard } from "@/lib/businessModules/schoolEntry/features/proced import { BUTTON_SIZE } from "@/lib/businessModules/schoolEntry/features/procedures/new/constants"; import { PROCEDURE_TYPE_OPTIONS_EXCLUDING_DRAFT } from "@/lib/businessModules/schoolEntry/features/procedures/options"; import { routes } from "@/lib/businessModules/schoolEntry/shared/routes"; -import { SidebarFormHandle } from "@/lib/shared/components/form/SidebarForm"; -import { PersonSidebar } from "@/lib/shared/components/personSidebar/PersonSidebar"; +import { + PersonSidebar, + PersonSidebarProps, +} from "@/lib/shared/components/personSidebar/PersonSidebar"; +import { DefaultPersonFormValues } from "@/lib/shared/components/personSidebar/form/DefaultPersonForm"; import { mapToPersonAddRequest } from "@/lib/shared/components/personSidebar/helpers"; import { DefaultSearchPersonForm, @@ -37,8 +40,10 @@ import { SearchPersonFormProps, SearchPersonFormValues, } from "@/lib/shared/components/personSidebar/search/SearchPersonSidebar"; -import { Sidebar } from "@/lib/shared/components/sidebar/Sidebar"; -import { useConfirmationDialog } from "@/lib/shared/hooks/useConfirmationDialog"; +import { + SidebarWithFormRefProps, + useSidebarWithFormRef, +} from "@/lib/shared/hooks/useSidebarWithFormRef"; interface EsuSearchForm extends SearchPersonFormValues { type: OptionalFieldValue<ApiSchoolEntryProcedureType>; @@ -70,25 +75,25 @@ function EsuSearchFormComponent(props: SearchPersonFormProps<EsuSearchForm>) { } export function CreateProcedureSidebar() { - const [open, setOpen] = useState(false); - const router = useRouter(); - const createProcedure = useCreateProcedure(); - const sidebarFormRef = useRef<SidebarFormHandle>(null); - const { openCancelDialog } = useConfirmationDialog(); + const personSidebar = useSidebarWithFormRef({ + component: ConfiguredPersonSidebar, + }); - function closeSidebar() { - setOpen(false); - } + return ( + <Button + startDecorator={<Add />} + onClick={() => personSidebar.open()} + size={BUTTON_SIZE} + > + Neuen Vorgang anlegen + </Button> + ); +} - function handleClose() { - if (sidebarFormRef.current?.dirty) { - openCancelDialog({ - onConfirm: closeSidebar, - }); - } else { - closeSidebar(); - } - } +function ConfiguredPersonSidebar(props: SidebarWithFormRefProps) { + const router = useRouter(); + const createProcedure = useCreateProcedure(); + const schoolEntryApi = useSchoolEntryApi(); async function handleCreate( child: ApiCreatePerson, @@ -98,55 +103,40 @@ export function CreateProcedureSidebar() { mapToCreateProcedureRequest(child, type), { onSuccess: (response) => { - closeSidebar(); router.push(routes.procedures.byId(response.procedureId).details); }, }, ); } - const schoolEntryApi = useSchoolEntryApi(); - return ( - <> - <Button - startDecorator={<Add />} - onClick={() => setOpen(true)} - size={BUTTON_SIZE} - > - Neuen Vorgang anlegen - </Button> - <Sidebar open={open} onClose={handleClose}> - {open && ( - <PersonSidebar - title={"Neuen Vorgang anlegen"} - onCancel={handleClose} - onCreate={async ({ searchInputs, createInputs }) => { - await handleCreate( - mapToPersonAddRequest(createInputs), - searchInputs.type, - ); - }} - onSelect={async ({ searchInputs, person }) => { - await handleCreate( - mapToPersonAddRequest(person), - searchInputs.type, - ); - }} - submitLabel={"Vorgang anlegen"} - sidebarFormRef={sidebarFormRef} - searchFormComponent={EsuSearchFormComponent} - initialSearchState={personSearchFormInitialValues} - addressRequired - associatedProcedures={{ - getQuery: (personId) => - getProceduresByPersonQuery(schoolEntryApi, personId), - cardComponent: ProcedureCard, - }} - /> - )} - </Sidebar> - </> - ); + const personSidebarProps: PersonSidebarProps< + EsuSearchForm, + DefaultPersonFormValues, + ApiProcedureDetails + > = { + title: "Neuen Vorgang anlegen", + onCreate: async ({ searchInputs, createInputs }) => { + await handleCreate( + mapToPersonAddRequest(createInputs), + searchInputs.type, + ); + }, + onSelect: async ({ searchInputs, person }) => { + await handleCreate(mapToPersonAddRequest(person), searchInputs.type); + }, + submitLabel: "Vorgang anlegen", + searchFormComponent: EsuSearchFormComponent, + initialSearchState: personSearchFormInitialValues, + addressRequired: true, + associatedProcedures: { + getQuery: (personId) => + getProceduresByPersonQuery(schoolEntryApi, personId), + cardComponent: ProcedureCard, + }, + ...props, + }; + + return <PersonSidebar {...personSidebarProps} />; } function mapToCreateProcedureRequest( diff --git a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/procedureDetails/AddCustodianPanel.tsx b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/procedureDetails/AddCustodianPanel.tsx index 551af3b402472a1757e2ad760fc5644e119ec9d4..0c9a1d68c0f6dfc667a7b4b7a11bee033b8af317 100644 --- a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/procedureDetails/AddCustodianPanel.tsx +++ b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/procedureDetails/AddCustodianPanel.tsx @@ -7,101 +7,77 @@ import { ApiGetReferencePersonResponse } from "@eshg/base-api"; import { ApiAddCustodianRequest } from "@eshg/school-entry-api"; import AddIcon from "@mui/icons-material/Add"; import { Button } from "@mui/joy"; -import { useRef, useState } from "react"; import { ProcedureDetails } from "@/lib/businessModules/schoolEntry/api/models/ProcedureDetails"; import { useAddPersonAsCustodian } from "@/lib/businessModules/schoolEntry/api/mutations/schoolEntryApi"; -import { OverlayBoundary } from "@/lib/shared/components/boundaries/OverlayBoundary"; import { ContentPanel } from "@/lib/shared/components/contentPanel/ContentPanel"; import { DetailsSection } from "@/lib/shared/components/detailsSection/DetailsSection"; -import { SidebarFormHandle } from "@/lib/shared/components/form/SidebarForm"; import { PersonSidebar } from "@/lib/shared/components/personSidebar/PersonSidebar"; import { DefaultPersonFormValues } from "@/lib/shared/components/personSidebar/form/DefaultPersonForm"; import { mapToPersonAddRequest } from "@/lib/shared/components/personSidebar/helpers"; -import { Sidebar } from "@/lib/shared/components/sidebar/Sidebar"; -import { useConfirmationDialog } from "@/lib/shared/hooks/useConfirmationDialog"; +import { + SidebarWithFormRefProps, + useSidebarWithFormRef, +} from "@/lib/shared/hooks/useSidebarWithFormRef"; export function AddCustodianPanel(props: { procedure: ProcedureDetails }) { - const [sidebarMode, setSidebarMode] = useState("none"); - const sidebarFormRef = useRef<SidebarFormHandle>(null); - const { openCancelDialog } = useConfirmationDialog(); - const addPersonAsCustodian = useAddPersonAsCustodian(props.procedure.id); + const personSidebar = useSidebarWithFormRef({ + component: ConfiguredPersonSidebar, + }); - function closeSidebar() { - setSidebarMode("none"); - } + return ( + <ContentPanel> + <DetailsSection + data-testid="add-custodian" + title="PSB - Personensorgeberechtigte:r" + > + <Button + color={"primary"} + variant={"plain"} + size={"sm"} + sx={{ justifyContent: "flex-start" }} + startDecorator={<AddIcon />} + onClick={() => personSidebar.open(props)} + > + Hinzufügen + </Button> + </DetailsSection> + </ContentPanel> + ); +} - function handleClose() { - if (sidebarFormRef.current?.dirty) { - openCancelDialog({ - onConfirm: closeSidebar, - }); - } else { - closeSidebar(); - } - } +function ConfiguredPersonSidebar( + props: { + procedure: ProcedureDetails; + } & SidebarWithFormRefProps, +) { + const addPersonAsCustodian = useAddPersonAsCustodian(props.procedure.id); async function handleCreate(values: DefaultPersonFormValues) { await addPersonAsCustodian.mutateAsync( mapToRequest(values, props.procedure.version), - { - onSuccess: closeSidebar, - }, ); } async function handleSelect(person: ApiGetReferencePersonResponse) { - await addPersonAsCustodian.mutateAsync( - { - custodian: { - ...person, - referenceId: person.id, - }, - procedureVersion: props.procedure.version, - }, - { - onSuccess: closeSidebar, + await addPersonAsCustodian.mutateAsync({ + custodian: { + ...person, + referenceId: person.id, }, - ); + procedureVersion: props.procedure.version, + }); } return ( - <> - <ContentPanel> - <DetailsSection - data-testid="add-custodian" - title="PSB - Personensorgeberechtigte:r" - > - <Button - color={"primary"} - variant={"plain"} - size={"sm"} - sx={{ justifyContent: "flex-start" }} - startDecorator={<AddIcon />} - onClick={() => setSidebarMode("add")} - > - Hinzufügen - </Button> - </DetailsSection> - </ContentPanel> - <OverlayBoundary> - <Sidebar - open={sidebarMode !== "none"} - onClose={() => setSidebarMode("none")} - > - {sidebarMode !== "none" && ( - <PersonSidebar - onCancel={handleClose} - onCreate={({ createInputs }) => handleCreate(createInputs)} - onSelect={({ person }) => handleSelect(person)} - sidebarFormRef={sidebarFormRef} - title="PSB hinzufügen" - submitLabel="Hinzufügen" - /> - )} - </Sidebar> - </OverlayBoundary> - </> + <PersonSidebar + title="PSB hinzufügen" + submitLabel="Hinzufügen" + onCreate={({ createInputs }) => handleCreate(createInputs)} + onSelect={({ person }) => handleSelect(person)} + onClose={props.onClose} + formRef={props.formRef} + /> ); } diff --git a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/translations.ts b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/translations.ts index cdfaa9b014dfddec81c1883ec5cb7eaa79817ab2..2767be3c732802c733fb8f46ec6203b00cf0a3ce 100644 --- a/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/translations.ts +++ b/employee-portal/src/lib/businessModules/schoolEntry/features/procedures/translations.ts @@ -188,7 +188,8 @@ export const APPOINTMENT_TYPES: EnumMap<ApiAppointmentType> = { [ApiAppointmentType.HivStiConsultation]: "HIV-STI-Beratung", [ApiAppointmentType.SexWork]: "Sexarbeit", [ApiAppointmentType.ResultsReview]: "Ergebnisbesprechung", - [ApiAppointmentType.OfficialMedicalService]: "Amtsärtzlicher Dienst", + [ApiAppointmentType.OfficialMedicalServiceShort]: "Kleine Untersuchung", + [ApiAppointmentType.OfficialMedicalServiceLong]: "Große Untersuchung", }; export const DISABILITY_TYPE_VALUES: EnumMap<ApiDisabilityType> = { diff --git a/employee-portal/src/lib/businessModules/schoolEntry/shared/sideNavigationItem.tsx b/employee-portal/src/lib/businessModules/schoolEntry/shared/sideNavigationItem.tsx index 78343d65a7e6d459f9955716002c291fac2d0e44..97b4410868ea899c296e57888694d8704f3ac5cf 100644 --- a/employee-portal/src/lib/businessModules/schoolEntry/shared/sideNavigationItem.tsx +++ b/employee-portal/src/lib/businessModules/schoolEntry/shared/sideNavigationItem.tsx @@ -5,15 +5,15 @@ import { ApiBaseFeature, ApiUserRole } from "@eshg/base-api"; import { hasUserRole } from "@eshg/lib-employee-portal/helpers/accessControl"; +import { + SideNavigationSubItem, + UseSideNavigationItemsResult, +} from "@eshg/lib-employee-portal/types/sideNavigation"; import { ApiLocationSelectionMode } from "@eshg/school-entry-api"; import { WcOutlined } from "@mui/icons-material"; import { useQuery } from "@tanstack/react-query"; import { useIsNewFeatureEnabled } from "@/lib/baseModule/api/queries/feature"; -import { - SideNavigationSubItem, - UseSideNavigationItemsResult, -} from "@/lib/baseModule/components/layout/sideNavigation/types"; import { useConfigApi } from "@/lib/businessModules/schoolEntry/api/clients"; import { getLocationSelectionModeQuery } from "@/lib/businessModules/schoolEntry/api/queries/configApi"; diff --git a/employee-portal/src/lib/businessModules/statistics/components/evaluations/details/EvaluationDetailsLayout.tsx b/employee-portal/src/lib/businessModules/statistics/components/evaluations/details/EvaluationDetailsLayout.tsx index dc300ead4060566055212cfff02bc67654d61f66..4c408dd02d520282d21011fc7ef120f19c0674b3 100644 --- a/employee-portal/src/lib/businessModules/statistics/components/evaluations/details/EvaluationDetailsLayout.tsx +++ b/employee-portal/src/lib/businessModules/statistics/components/evaluations/details/EvaluationDetailsLayout.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; import { BookOutlined, DiamondOutlined, @@ -16,7 +17,6 @@ import { EvaluationDetailsTabHeaderProps, } from "@/lib/businessModules/statistics/components/evaluations/details/EvaluationDetailsTabHeader"; import { routes } from "@/lib/businessModules/statistics/shared/routes"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; import { TabNavigationItem } from "@/lib/shared/components/tabNavigation/types"; import { TabNavigationToolbar } from "@/lib/shared/components/tabNavigationToolbar/TabNavigationToolbar"; 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 9f8b0d555576be3c63e3c9d092d615f23a08ff04..d3f3f8cde792f8190dc0f33582e85fb7fb7b168a 100644 --- a/employee-portal/src/lib/businessModules/statistics/components/reports/ReportDetailsTile.tsx +++ b/employee-portal/src/lib/businessModules/statistics/components/reports/ReportDetailsTile.tsx @@ -3,12 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { useLayoutConfig } from "@eshg/lib-employee-portal/contexts/layoutConfig"; +import { useHeaderHeights } from "@eshg/lib-employee-portal/hooks/useHeaderHeights"; import { formatDate } from "@eshg/lib-portal/formatters/dateTime"; import { Divider, Sheet, Stack, Typography } from "@mui/joy"; import { isNonNullish } from "remeda"; -import { simpleToolbarHeight } from "@/lib/baseModule/components/layout/sizes"; -import { useHeaderHeights } from "@/lib/baseModule/components/layout/useHeaderHeights"; import { useExportReportData } from "@/lib/businessModules/statistics/api/downloads/useExportReportData"; import { DataSourceSensitivity, @@ -47,6 +47,7 @@ export interface ReportDetailsTileProps { } export function ReportDetailsTile(props: ReportDetailsTileProps) { + const { simpleToolbarHeight } = useLayoutConfig(); const updateReportSidebar = useUpdateReportSidebar(); const canWrite = useStatisticsRoleChecks().canWrite(); const canDelete = useStatisticsRoleChecks().canDelete(props.userId); diff --git a/employee-portal/src/lib/businessModules/statistics/components/shared/charts/BarChart.tsx b/employee-portal/src/lib/businessModules/statistics/components/shared/charts/BarChart.tsx index 8fb440f23ab9c142ea2305256a27b632ab1fd8b3..36aee3b35ec5e300360b4c063f6c3ee53e2659fa 100644 --- a/employee-portal/src/lib/businessModules/statistics/components/shared/charts/BarChart.tsx +++ b/employee-portal/src/lib/businessModules/statistics/components/shared/charts/BarChart.tsx @@ -10,12 +10,12 @@ import { DiagramGrouping, DiagramOrientation, DiagramScaling, - DiagramType, } from "@/lib/businessModules/statistics/api/models/evaluationDetailsViewTypes"; import { ChartApi, EChart, } from "@/lib/businessModules/statistics/components/shared/charts/EChart"; +import { evaluateGrouping } from "@/lib/businessModules/statistics/components/shared/charts/chartHelper"; import { calculateRelativeFormatting, formatChartLabel, @@ -27,9 +27,6 @@ export interface BarChartProps { scaling?: DiagramScaling; orientation?: DiagramOrientation; eChartApi?: (eChartApi: ChartApi) => void; - barWidth?: string; - barGap?: number; - type?: DiagramType.BAR_CHART | DiagramType.HISTOGRAM_CHART; } export function mapToUnstackedSeries( @@ -107,19 +104,6 @@ export function transformToRelativeData(data: DataGroups | number[]) { ); } -function evaluateGrouping( - grouping: DiagramGrouping | undefined, - scaling: DiagramScaling | undefined, -) { - if (grouping === "STACKED") { - if (scaling === "RELATIVE") { - return "total"; - } - return "x"; - } - return undefined; -} - export function BarChart(props: BarChartProps) { const grouping = evaluateGrouping(props.grouping, props.scaling); const isStackedSeries = (props.diagramData[0]?.attributes?.length ?? 0) > 1; @@ -148,22 +132,7 @@ export function BarChart(props: BarChartProps) { return formatChartLabel(text, 330); }, hideOverlap: false, - interval: - props.orientation === "VERTICAL" && - props.type !== DiagramType.HISTOGRAM_CHART - ? 0 - : undefined, - }, - axisLine: { - show: props.type === DiagramType.HISTOGRAM_CHART, - }, - axisTick: { - show: props.type === DiagramType.HISTOGRAM_CHART, - interval: 0, - }, - splitLine: { - show: props.type === DiagramType.HISTOGRAM_CHART, - interval: 0, + interval: props.orientation === "VERTICAL" ? 0 : undefined, }, }; const valueAxisOption: EChartsOption["xAxis"] & EChartsOption["yAxis"] = { @@ -201,16 +170,12 @@ export function BarChart(props: BarChartProps) { type: "bar", data: (series.data as DataGroups)[serie]!.map((it) => it.value), stack: grouping, - barWidth: props.barWidth, - barGap: props.barGap, }; }) : [ { type: "bar", data: series.data as number[], - barWidth: props.barWidth, - barGap: props.barGap, }, ], }; diff --git a/employee-portal/src/lib/businessModules/statistics/components/shared/charts/Histogram.tsx b/employee-portal/src/lib/businessModules/statistics/components/shared/charts/Histogram.tsx index 5ab320b632775b0f42517acf97791f19c338821b..f13931fcb0ea6116c007eda066b7f2c394c2722d 100644 --- a/employee-portal/src/lib/businessModules/statistics/components/shared/charts/Histogram.tsx +++ b/employee-portal/src/lib/businessModules/statistics/components/shared/charts/Histogram.tsx @@ -3,15 +3,19 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { EChartsOption, SeriesOption } from "echarts"; + import { - AnalysisDiagramBarChart, AnalysisDiagramHistogram, DiagramGrouping, DiagramScaling, - DiagramType, } from "@/lib/businessModules/statistics/api/models/evaluationDetailsViewTypes"; -import { BarChart } from "@/lib/businessModules/statistics/components/shared/charts/BarChart"; -import { ChartApi } from "@/lib/businessModules/statistics/components/shared/charts/EChart"; +import { + ChartApi, + EChart, +} from "@/lib/businessModules/statistics/components/shared/charts/EChart"; +import { evaluateGrouping } from "@/lib/businessModules/statistics/components/shared/charts/chartHelper"; +import { calculateRelativeFormatting } from "@/lib/businessModules/statistics/components/shared/charts/dataHelper"; interface HistogramProps { diagramData: AnalysisDiagramHistogram["data"]; @@ -20,37 +24,127 @@ interface HistogramProps { eChartApi?: (eChartApi: ChartApi) => void; } -export function mapToBarChartDiagramData( +type DataGroups = Record<string, [number, number][]>; + +export function mapToStackedSeries( diagramData: AnalysisDiagramHistogram["data"], -): AnalysisDiagramBarChart["data"] { - // On a 1920 width display 15 Bars barely fit the whole label - const tooManyBars = diagramData.length > 15; - return diagramData - .toSorted((l, r) => l.min - r.min) - .map((it) => ({ - label: tooManyBars - ? `${it.min.toFixed(2)}` - : `${it.min.toFixed(2)} - ${it.max.toFixed(2)}`, - attributes: it.attributes, - })); +) { + const dataGroups: DataGroups = {}; + const sortedData = diagramData.toSorted((l, r) => l.min - r.min); + + sortedData.forEach((item) => { + item.attributes.forEach((attribute) => { + if (!dataGroups[attribute.label]) { + dataGroups[attribute.label] = []; + } + dataGroups[attribute.label]!.push([item.min, attribute.value]); + }); + }); + return { + min: sortedData[0]!.min, + max: sortedData[sortedData.length - 1]!.max, + dataGroups, + }; +} + +function transformToRelativeData(dataGroups: DataGroups) { + function mapToRelative(value: number, total: number) { + if (total === 0) { + return 0; + } + return value / total; + } + + const totals = Object.keys(dataGroups).reduce( + (acc, it) => { + dataGroups[it]!.forEach(([x, y]) => { + acc[x] = (acc[x] ?? 0) + y; + }); + return acc; + }, + {} as Record<string, number>, + ); + + return Object.keys(dataGroups).reduce( + (acc, it) => ({ + ...acc, + [it]: dataGroups[it]!.map(([x, y]) => [x, mapToRelative(y, totals[x]!)]), + }), + {}, + ); } export function Histogram(props: HistogramProps) { - const data = mapToBarChartDiagramData(props.diagramData); + const series = mapToStackedSeries(props.diagramData); const numAttributes = props.diagramData[0]?.attributes?.length ?? 1; + const isStackedSeries = numAttributes > 1; const barWidth = props.grouping === "STACKED" ? "99.8%" : `${99.8 / numAttributes}%`; + const grouping = evaluateGrouping(props.grouping, props.scaling); - return ( - <BarChart - diagramData={data} - grouping={props.grouping} - scaling={props.scaling} - orientation={"VERTICAL"} - eChartApi={props.eChartApi} - barGap={0} - barWidth={barWidth} - type={DiagramType.HISTOGRAM_CHART} - /> - ); + if (props.scaling === "RELATIVE") { + series.dataGroups = transformToRelativeData(series.dataGroups); + } + + function formatter(value: number) { + return props.scaling !== "RELATIVE" + ? `${value}` + : calculateRelativeFormatting(value); + } + + const seriesData = Object.keys(series.dataGroups).map((serie) => { + return { + name: serie, + type: "bar", + data: series.dataGroups[serie]!, + stack: grouping, + barWidth: barWidth, + barGap: 0, + xAxisIndex: 0, + }; + }) satisfies SeriesOption[]; + + const option: EChartsOption = { + xAxis: [ + // We require two axis to trick ECharts to stack bars properly. + // https://github.com/apache/echarts/issues/7937#issuecomment-375918207 + { + type: "category", + show: false, + }, + { + type: "value", + min: series.min, + max: series.max, + position: "bottom", + }, + ], + yAxis: { + type: "value", + splitLine: { show: true }, + axisLabel: { + formatter, + }, + axisLine: { + onZero: false, + }, + }, + tooltip: { + show: true, + valueFormatter: (params) => formatter(params as number), + }, + grid: { + containLabel: true, + }, + series: isStackedSeries + ? seriesData + : [ + { + ...seriesData[0]!, + name: undefined, + }, + ], + }; + + return <EChart option={option} chartApi={props.eChartApi} />; } diff --git a/employee-portal/src/lib/businessModules/statistics/components/shared/charts/chartHelper.ts b/employee-portal/src/lib/businessModules/statistics/components/shared/charts/chartHelper.ts index 5af5b96d45645ff83e9077cecd1a2675e4eb9939..2c75f77049e6217f294003a21d803aed58edb2b9 100644 --- a/employee-portal/src/lib/businessModules/statistics/components/shared/charts/chartHelper.ts +++ b/employee-portal/src/lib/businessModules/statistics/components/shared/charts/chartHelper.ts @@ -104,10 +104,30 @@ export function isText(valueType: AttributeType) { export function isCategorical(valueType: AttributeType) { return ( - isBoolean(valueType) || isValueWithOptions(valueType) || isText(valueType) + isBoolean(valueType) || + isValueWithOptions(valueType) || + isText(valueType) || + isInteger(valueType) ); } +export function isInteger(valueType: AttributeType) { + return valueType === "IntegerAttribute"; +} + export function isNumeric(valueType: AttributeType) { - return valueType === "DecimalAttribute" || valueType === "IntegerAttribute"; + return valueType === "DecimalAttribute" || isInteger(valueType); +} + +export function evaluateGrouping( + grouping: DiagramGrouping | undefined, + scaling: DiagramScaling | undefined, +) { + if (grouping === "STACKED") { + if (scaling === "RELATIVE") { + return "total"; + } + return "x"; + } + return undefined; } diff --git a/employee-portal/src/lib/businessModules/statistics/shared/sideNavigationItem.tsx b/employee-portal/src/lib/businessModules/statistics/shared/sideNavigationItem.tsx index 25f145b111a42c7560d639f2ba8423191a9aaa5d..893f8bbba6ee375f1db7a1a7881f6d2eb6cbcf1b 100644 --- a/employee-portal/src/lib/businessModules/statistics/shared/sideNavigationItem.tsx +++ b/employee-portal/src/lib/businessModules/statistics/shared/sideNavigationItem.tsx @@ -8,11 +8,10 @@ import { hasAnyUserRoles, hasUserRole, } from "@eshg/lib-employee-portal/helpers/accessControl"; +import { UseSideNavigationItemsResult } from "@eshg/lib-employee-portal/types/sideNavigation"; import { BarChartOutlined } from "@mui/icons-material"; import { isPlainObject } from "remeda"; -import { UseSideNavigationItemsResult } from "@/lib/baseModule/components/layout/sideNavigation/types"; - import { routes } from "./routes"; export function useSideNavigationItems(): UseSideNavigationItemsResult { diff --git a/employee-portal/src/lib/businessModules/stiProtection/api/mutations/procedures.ts b/employee-portal/src/lib/businessModules/stiProtection/api/mutations/procedures.ts index 66307a66455ac9b9ecebbbf654b95ec0ac7237d7..f2956eef7f527fd9b5c8631a9306d40c1656afe8 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/api/mutations/procedures.ts +++ b/employee-portal/src/lib/businessModules/stiProtection/api/mutations/procedures.ts @@ -209,6 +209,20 @@ export function useCancelAppointmentMutation({ }); } +export function useFinalizeAppointmentMutation({ + onSuccess, + onError, +}: MutationPassThrough<string, void> = {}) { + const api = useStiProtectionProcedureApi(); + + return useHandledMutation({ + mutationFn: (id: string) => api.finalizeAppointment(id), + mutationKey: stiProtectionApiQueryKey(["appointment", "finalize"]), + onSuccess, + onError, + }); +} + interface UpdateAppointmentParams { id: string; data: ApiUpdateAppointmentRequest; diff --git a/employee-portal/src/lib/businessModules/stiProtection/api/queries/examination.ts b/employee-portal/src/lib/businessModules/stiProtection/api/queries/examination.ts index c283705acbe0c2563937f69eee56c96f9aeec9f3..641172e1384987cce4bf2b27ff65ea5b25d22577 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/api/queries/examination.ts +++ b/employee-portal/src/lib/businessModules/stiProtection/api/queries/examination.ts @@ -9,7 +9,7 @@ import { useExaminationApi } from "@/lib/businessModules/stiProtection/api/clien import { stiProtectionApiQueryKey } from "./apiQueryKeys"; -function useGetRapidTestExaminationQueryOptions(procedureId: string) { +export function useGetRapidTestExaminationQueryOptions(procedureId: string) { const examinationApi = useExaminationApi(); return queryOptions({ diff --git a/employee-portal/src/lib/businessModules/stiProtection/api/queries/identity.ts b/employee-portal/src/lib/businessModules/stiProtection/api/queries/identity.ts index 1ca685d0070f08f4a753dbe1071e1ad67083e550..0ce717fee3ac4ca1476ba9f7098f0764d1dbbd3c 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/api/queries/identity.ts +++ b/employee-portal/src/lib/businessModules/stiProtection/api/queries/identity.ts @@ -24,6 +24,6 @@ export function usePinCheck(procedureId: string, pin: string | undefined) { }, queryKey: ["pin-validation", procedureId, pin], enabled: pin != null, - staleTime: STATIC_QUERY_OPTIONS.staleTime, + ...STATIC_QUERY_OPTIONS, }); } diff --git a/employee-portal/src/lib/businessModules/stiProtection/components/procedures/proceduresTable/StiProtectionProceduresTable.tsx b/employee-portal/src/lib/businessModules/stiProtection/components/procedures/proceduresTable/StiProtectionProceduresTable.tsx index 0d659968e7c918c25ee5e71c293969a4a4ccccb0..02ce24d091457376008da03f8052a047e3e5ad34 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/components/procedures/proceduresTable/StiProtectionProceduresTable.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/components/procedures/proceduresTable/StiProtectionProceduresTable.tsx @@ -13,10 +13,12 @@ import { import { ApiBusinessModule } from "@eshg/lib-procedures-api"; import { ApiStiProtectionProcedureOverview } from "@eshg/sti-protection-api"; import { EditOutlined, ToggleOffOutlined } from "@mui/icons-material"; +import { Chip } from "@mui/joy"; import { useSuspenseQueries } from "@tanstack/react-query"; import { ColumnSort, createColumnHelper } from "@tanstack/react-table"; import { useStiProceduresQuery } from "@/lib/businessModules/stiProtection/api/queries/procedures"; +import { DisplayAccessCode } from "@/lib/businessModules/stiProtection/features/procedures/DisplayAccessCode"; import { ReopenConfirmationDialog, UseCloseAndReopenConfirmationDialog, @@ -25,7 +27,9 @@ import { import { CONCERN_VALUES, GENDER_VALUES, + LAB_STATUS_COLORS, LAB_STATUS_VALUES, + PROCEDURE_STATUS_COLORS, PROCEDURE_STATUS_VALUES, } from "@/lib/businessModules/stiProtection/shared/constants"; import { isProcedureOpen } from "@/lib/businessModules/stiProtection/shared/helpers"; @@ -60,9 +64,10 @@ function getProceduresColumns({ return [ columnHelper.accessor("accessCode", { header: "Anmeldecode", - cell: ({ getValue }) => getValue(), + cell: (props) => <DisplayAccessCode code={props.getValue()} />, enableSorting: false, meta: { + width: 200, canNavigate: { parentRow: true, }, @@ -90,7 +95,11 @@ function getProceduresColumns({ }), columnHelper.accessor("status", { header: "Status", - cell: ({ getValue }) => PROCEDURE_STATUS_VALUES[getValue()], + cell: ({ getValue }) => ( + <Chip color={PROCEDURE_STATUS_COLORS[getValue()]}> + {PROCEDURE_STATUS_VALUES[getValue()]} + </Chip> + ), enableSorting: false, meta: { canNavigate: { @@ -140,7 +149,11 @@ function getProceduresColumns({ }), columnHelper.accessor("labStatus", { header: "Laborstatus", - cell: ({ getValue }) => LAB_STATUS_VALUES[getValue()], + cell: ({ getValue }) => ( + <Chip color={LAB_STATUS_COLORS[getValue()]}> + {LAB_STATUS_VALUES[getValue()]} + </Chip> + ), enableSorting: false, meta: { canNavigate: { diff --git a/employee-portal/src/lib/businessModules/stiProtection/components/textTemplates/TextTemplatesOverviewTable.tsx b/employee-portal/src/lib/businessModules/stiProtection/components/textTemplates/TextTemplatesOverviewTable.tsx index 991b72864018b10545df98134725140ea19b6a1e..082df986760072790236c73b1fa13324c486c4f5 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/components/textTemplates/TextTemplatesOverviewTable.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/components/textTemplates/TextTemplatesOverviewTable.tsx @@ -99,7 +99,7 @@ export function TextTemplatesOverviewTable() { <EmployeePortalConfirmationDialog open={confirmingDelete != null} title="Vorlage löschen?" - description="Möchten Sie die Vorlage “Rechtliche Grundlage†wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden." + description={`Möchten Sie die Vorlage ${textTemplates.find((template) => template.externalId == confirmingDelete)?.name} wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden.`} confirmLabel="Löschen" color="danger" onCancel={() => { diff --git a/employee-portal/src/lib/businessModules/stiProtection/components/textTemplates/TextareaFieldWithTextTemplates.tsx b/employee-portal/src/lib/businessModules/stiProtection/components/textTemplates/TextareaFieldWithTextTemplates.tsx index 8ab1ed2c70156a126b6dac1cf001ad3d8924bfad..0a35242cc4fdf2322a0c3d25551304bebf5bc945 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/components/textTemplates/TextareaFieldWithTextTemplates.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/components/textTemplates/TextareaFieldWithTextTemplates.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { useIsFormDisabled } from "@eshg/lib-portal/components/form/DisabledFormContext"; import { ApiTextTemplateContext } from "@eshg/sti-protection-api"; import { Add } from "@mui/icons-material"; import { Button, styled } from "@mui/joy"; @@ -33,6 +34,7 @@ export function TextareaFieldWithTextTemplates({ }: TextareaWithTextTemplatesProps) { const { setFieldValue, getFieldMeta } = useFormikContext(); const { value } = getFieldMeta(props.name); + const disabled = useIsFormDisabled(); const ref = useRef<HTMLTextAreaElement | null>(null); const appendTextRef = useRef<AppendText | null>(null); @@ -83,15 +85,17 @@ export function TextareaFieldWithTextTemplates({ {...props} slotProps={{ textarea: { ref, rows: 20, onKeyDownCapture: onKeyDown } }} /> - <Button - startDecorator={<Add />} - aria-keyshortcuts="Control+Space" - variant="plain" - onClick={open} - title="Menü der Textvorlagen öffnen (Strg+Leertaste)" - > - Textvorlage einfügen - </Button> + {!disabled && ( + <Button + startDecorator={<Add />} + aria-keyshortcuts="Control+Space" + variant="plain" + onClick={open} + title="Menü der Textvorlagen öffnen (Strg+Leertaste)" + > + Textvorlage einfügen + </Button> + )} </FieldSetColumn> ); } diff --git a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/ProcedureToolbar.tsx b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/ProcedureToolbar.tsx index 43d7c1bb82e4f39f19b4c0178753d91036aa05e5..70d38684686d1afb2eaf4cf3435c5b4b44c53919 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/ProcedureToolbar.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/ProcedureToolbar.tsx @@ -13,6 +13,8 @@ import { TextSnippetOutlined, TimelineOutlined, } from "@mui/icons-material"; +import { CircularProgress } from "@mui/joy"; +import { useIsFetching } from "@tanstack/react-query"; import { routes } from "@/lib/businessModules/stiProtection/shared/routes"; import { PersonDocumentConsultation } from "@/lib/shared/components/icons/PersonDocumentConsultation"; @@ -35,6 +37,7 @@ export function ProcedureToolbar({ items={tabItems} routeBack={hasStiProtectionUserRole ? routes.procedures.index : undefined} header={<ProcedureTabHeader procedureId={procedureId} />} + afterTabs={<DisplayLoadingState />} /> ); } @@ -58,7 +61,7 @@ function buildTabItems(id: string): TabNavigationItem[] { }, { tabButtonName: "Untersuchung", - href: routes.procedures.byId(id).rapidTest, + href: routes.procedures.byId(id).examination.index, decorator: <MedicalServicesOutlined />, }, { @@ -73,3 +76,9 @@ function buildTabItems(id: string): TabNavigationItem[] { }, ]; } + +function DisplayLoadingState() { + const isFetching = useIsFetching(); + + return isFetching ? <CircularProgress /> : null; +} diff --git a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/TabStickyBottomButtonBar.tsx b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/TabStickyBottomButtonBar.tsx index f1358ef9134ccd3bfbb0ed15dbb9f7ab0eedd32d..5846102f764a695625777d97679998e8ecc90629 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/TabStickyBottomButtonBar.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/TabStickyBottomButtonBar.tsx @@ -3,48 +3,64 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { BottomToolbar } from "@eshg/lib-employee-portal/components/toolbar/BottomToolbar"; import { SubmitButton } from "@eshg/lib-portal/components/buttons/SubmitButton"; -import { ApiStiProtectionProcedure } from "@eshg/sti-protection-api"; +import { useIsFormDisabled } from "@eshg/lib-portal/components/form/DisabledFormContext"; import { Button } from "@mui/joy"; +import { useQueryClient } from "@tanstack/react-query"; import { useFormikContext } from "formik"; -import { useRouter } from "next/navigation"; -import { routes } from "@/lib/businessModules/stiProtection/shared/routes"; -import { StickyBottomButtonBar } from "@/lib/shared/components/buttons/StickyBottomButtonBar"; +import { stiProtectionApiQueryKey } from "@/lib/businessModules/stiProtection/api/queries/apiQueryKeys"; +import { useOnCancelForm } from "@/lib/businessModules/stiProtection/shared/helpers"; +import { ButtonBar } from "@/lib/shared/components/buttons/ButtonBar"; +import { StickyBottomBox } from "@/lib/shared/components/layout/StickyBottomBox"; export interface TabStickyBottomButtonBarProps { onCancel?: () => void; - procedure: ApiStiProtectionProcedure; } export function TabStickyBottomButtonBar({ - procedure, onCancel, }: TabStickyBottomButtonBarProps) { - const router = useRouter(); - const { isSubmitting } = useFormikContext(); + const queryClient = useQueryClient(); + const { isSubmitting, dirty, resetForm } = useFormikContext(); + const disabled = useIsFormDisabled(); + + const onCancelForm = useOnCancelForm(); + + if (disabled) { + return null; + } return ( - <StickyBottomButtonBar - sx={{ padding: "0.75rem 1.5rem" }} - right={ - <> - <Button - variant="plain" - onClick={() => { - if (onCancel) { - onCancel(); - } else { - router.push(routes.procedures.byId(procedure.id).details); - } - }} - aria-disabled={isSubmitting} - > - Abbrechen - </Button> - <SubmitButton submitting={isSubmitting}>Speichern</SubmitButton> - </> - } - ></StickyBottomButtonBar> + <StickyBottomBox> + <BottomToolbar sx={{ padding: "0.75rem 1.5rem" }}> + <ButtonBar + right={ + <> + <Button + variant="plain" + onClick={() => { + onCancelForm({ + dirty, + reset: resetForm, + onConfirm() { + void queryClient.invalidateQueries({ + queryKey: stiProtectionApiQueryKey([]), + }); + }, + }); + onCancel?.(); + }} + aria-disabled={isSubmitting} + > + Abbrechen + </Button> + <SubmitButton submitting={isSubmitting}>Speichern</SubmitButton> + </> + } + /> + </BottomToolbar> + </StickyBottomBox> ); } diff --git a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/consultation/ConsultationForm.tsx b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/consultation/ConsultationForm.tsx index dd3c64a5713cb07c341e88c52ff477ca7563e423..9ec4e74c63f8109f86e858c6842e4b5869d3b2df 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/consultation/ConsultationForm.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/consultation/ConsultationForm.tsx @@ -88,10 +88,11 @@ export function ConsultationForm({ name="general.notes" label="Allgemeine Bemerkungen" context={ApiTextTemplateContext.ConsultationRemark} + minRows={5} /> </SidecarSheet> </SidecarFormLayout> - <TabStickyBottomButtonBar procedure={procedure} /> + <TabStickyBottomButtonBar /> </FormPlus> )} </Formik> diff --git a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/details/AnonIdentityDocumentCard.tsx b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/details/AnonIdentityDocumentCard.tsx index 53a14c1e2d7d0344e474600966cb5facdcf03292..3b16b0fbcb802184c2c20fbfee5809a42dd2b907 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/details/AnonIdentityDocumentCard.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/details/AnonIdentityDocumentCard.tsx @@ -6,6 +6,7 @@ import { ButtonLink } from "@eshg/lib-portal/components/buttons/ButtonLink"; import { ApiStiProtectionProcedure } from "@eshg/sti-protection-api"; import { Sheet, Stack } from "@mui/joy"; +import { isDefined } from "remeda"; import { useAnonymousIdentificationDocumentQuery } from "@/lib/businessModules/stiProtection/api/queries/procedures"; import { DisplayAccessCode } from "@/lib/businessModules/stiProtection/features/procedures/DisplayAccessCode"; @@ -18,6 +19,7 @@ export function AnonIdentityDocumentCard({ }: Readonly<{ procedure: ApiStiProtectionProcedure }>) { const anonymousIdentificationDocument = useAnonymousIdentificationDocumentQuery(procedure.id); + const hasAppointment = isDefined(procedure.appointment); return ( <Sheet> @@ -30,19 +32,26 @@ export function AnonIdentityDocumentCard({ <DisplayAccessCode code={procedure.person.accessCode} bold /> } /> - <DetailsCell - label="Identifizierungs-Dokument als PDF" - valueIsDiv - value={ - <Stack direction="row" gap={1}> - <ButtonLink - onClick={() => anonymousIdentificationDocument.download()} - > - PDF herunterladen - </ButtonLink> - </Stack> - } - /> + {hasAppointment ? ( + <DetailsCell + label="Identifizierungs-Dokument als PDF" + valueIsDiv + value={ + <Stack direction="row" gap={1}> + <ButtonLink + onClick={() => anonymousIdentificationDocument.download()} + > + PDF herunterladen + </ButtonLink> + </Stack> + } + /> + ) : ( + <DetailsCell + label="Identifizierungs-Dokument als PDF" + value="Zum Download des Dokuments ist ein aktueller Termin erforderlich." + /> + )} </DetailsColumn> </DetailsSection> </Sheet> diff --git a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/details/AppointmentDetails.tsx b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/details/AppointmentDetails.tsx index 16a23e44274d88554907022eddcd86954f60d842..eed4ed617b4632579426ea7c0c2c462d1e768d57 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/details/AppointmentDetails.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/details/AppointmentDetails.tsx @@ -9,11 +9,14 @@ import { ApiAppointmentHistoryEntry, ApiStiProtectionProcedure, } from "@eshg/sti-protection-api"; -import { EditCalendar, EventBusy } from "@mui/icons-material"; +import { CheckCircle, EditCalendar, EventBusy } from "@mui/icons-material"; import { Button, Chip, Sheet, Stack } from "@mui/joy"; import { ColumnSort, createColumnHelper } from "@tanstack/react-table"; -import { useCancelAppointmentMutation } from "@/lib/businessModules/stiProtection/api/mutations/procedures"; +import { + useCancelAppointmentMutation, + useFinalizeAppointmentMutation, +} from "@/lib/businessModules/stiProtection/api/mutations/procedures"; import { APPOINTMENT_STATUS, APPOINTMENT_TYPES, @@ -87,6 +90,16 @@ export function AppointmentDetails({ setEditAppointmentType(appointmentType); } + const finalizeAppointment = useFinalizeAppointmentMutation({ + onSuccess: () => { + snackbar.confirmation("Der Termin wurde als abgeschlossen markiert."); + }, + }); + + function handleFinalizeAppointment() { + finalizeAppointment.mutate(procedure.id); + } + const onlyIfOpen = createOnlyIfProcedureOpen(procedure); return ( <Sheet> @@ -102,6 +115,7 @@ export function AppointmentDetails({ procedure, handleCancelAppointment, handleEditAppointment, + handleFinalizeAppointment, )} sorting={tableControl.tableSorting} enableSortingRemoval={false} @@ -131,6 +145,7 @@ function appointmentDetailsColumns( _procedure: ApiStiProtectionProcedure, onCancelAppointment: () => void, onEditAppointment: (appointmentType: string) => void, + onFinalizeAppointment: () => void, ) { function createActionButtons( appointmentHistoryEntry: ApiAppointmentHistoryEntry, @@ -148,6 +163,11 @@ function appointmentDetailsColumns( onClick: onCancelAppointment, startDecorator: <EventBusy />, }, + { + label: "Termin abschließen", + onClick: onFinalizeAppointment, + startDecorator: <CheckCircle />, + }, ] : []; } diff --git a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/details/PersonDetails.tsx b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/details/PersonDetails.tsx index 400cd832026ae42de33a20980fd9c53146f58831..3ec8864b502f69e1f17f51c403f24957ea0aa676 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/details/PersonDetails.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/details/PersonDetails.tsx @@ -47,7 +47,6 @@ export function PersonDetails({ width="100%" > <DetailsColumn> - <DetailsCell label="Aktenzeichen" value="-" /> <DetailsCell label="Geburtsjahr" value={procedure.person.yearOfBirth.toString()} diff --git a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/details/WaitingRoomSection.tsx b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/details/WaitingRoomSection.tsx index 6e2296090ce48b90d4027d6e84c2f9ccc7ffa86c..2f387e0edf4321addb455b2785af0eb0fdc6dfd1 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/details/WaitingRoomSection.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/details/WaitingRoomSection.tsx @@ -5,6 +5,10 @@ import { Row } from "@eshg/lib-portal/components/Row"; import { SubmitButton } from "@eshg/lib-portal/components/buttons/SubmitButton"; +import { + DisabledFormProvider, + useIsFormDisabled, +} from "@eshg/lib-portal/components/form/DisabledFormContext"; import { FormPlus } from "@eshg/lib-portal/components/form/FormPlus"; import { InputField } from "@eshg/lib-portal/components/formFields/InputField"; import { SelectField } from "@eshg/lib-portal/components/formFields/SelectField"; @@ -17,6 +21,7 @@ import { } from "@eshg/sti-protection-api"; import { Button, Sheet } from "@mui/joy"; import { Formik, useFormikContext } from "formik"; +import { useTransition } from "react"; import { useUpdateWaitingRoomDetails } from "@/lib/businessModules/stiProtection/api/mutations/waitingRoomApi"; import { WAITING_STATUS_OPTIONS } from "@/lib/businessModules/stiProtection/features/procedures/translations"; @@ -51,56 +56,69 @@ export function WaitingRoomSection({ snackbar.confirmation("Wartezimmerdaten aktualisiert"); }, }); + const [isResetting, startReset] = useTransition(); const onlyIfOpen = createOnlyIfProcedureOpen(procedure); const isDisabled = - !isProcedureOpen(procedure) || updateWaitingRoomDetails.isPending; + !isProcedureOpen(procedure) || + updateWaitingRoomDetails.isPending || + isResetting; return ( <Sheet> <DetailsSection title="Wartezimmer"> - <Formik - enableReinitialize - initialValues={initialValues(procedure.waitingRoom)} - onSubmit={(form) => - updateWaitingRoomDetails.mutate(transformToValid(form, procedure)) - } - > - <FormPlus sx={{ display: "contents" }}> - <InputField - label="Zusätzliche Info" - name="info" - disabled={isDisabled} - maxLength={ADDITIONAL_INFO_MAX_LENGTH} - /> - <SelectField - label="Status" - name="status" - disabled={isDisabled} - options={WAITING_STATUS_OPTIONS} - /> - {onlyIfOpen( - <FormButtons isSubmitting={updateWaitingRoomDetails.isPending} />, - )} - </FormPlus> - </Formik> + <DisabledFormProvider disabled={isDisabled}> + <Formik + enableReinitialize + initialValues={initialValues(procedure.waitingRoom)} + onSubmit={(form) => + updateWaitingRoomDetails.mutate(transformToValid(form, procedure)) + } + > + <FormPlus sx={{ display: "contents" }}> + <InputField + label="Zusätzliche Info" + name="info" + maxLength={ADDITIONAL_INFO_MAX_LENGTH} + /> + <SelectField + label="Status" + name="status" + options={WAITING_STATUS_OPTIONS} + /> + {onlyIfOpen(<FormButtons startReset={startReset} />)} + </FormPlus> + </Formik> + </DisabledFormProvider> </DetailsSection> </Sheet> ); } -function FormButtons({ isSubmitting }: { isSubmitting: boolean }) { +function FormButtons({ + startReset: startReset, +}: { + startReset: (action: () => Promise<void>) => void; +}) { const { setValues } = useFormikContext<WaitingRoomDetails>(); + const disabled = useIsFormDisabled(); + + function resetForm() { + startReset(async () => { + await setValues({ info: "", status: null }); + }); + } + return ( <Row justifyContent="right"> <Button variant="plain" - onClick={() => setValues({ info: "", status: null })} - aria-disabled={isSubmitting} + onClick={() => resetForm()} + aria-disabled={disabled} > Zurücksetzen </Button> - <SubmitButton submitting={isSubmitting}>Speichern</SubmitButton> + <SubmitButton submitting={disabled}>Speichern</SubmitButton> </Row> ); } diff --git a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/diagnosis/DiagnosisForm.tsx b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/diagnosis/DiagnosisForm.tsx index ff9b6cbbe07bd997bae1e48ac77f945d265c4b25..90b7f9656ddbc6146cd858cb464f247e47e7afce 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/diagnosis/DiagnosisForm.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/diagnosis/DiagnosisForm.tsx @@ -22,7 +22,6 @@ import { FieldArray, FieldArrayRenderProps, Formik, - FormikProps, useFormikContext, } from "formik"; import { PropsWithChildren } from "react"; @@ -38,7 +37,6 @@ import { SidecarSheet, } from "@/lib/businessModules/stiProtection/features/procedures/SidecarFormLayout"; import { TabStickyBottomButtonBar } from "@/lib/businessModules/stiProtection/features/procedures/TabStickyBottomButtonBar"; -import { useOnCancelForm } from "@/lib/businessModules/stiProtection/shared/helpers"; import { ConfirmLeaveDirtyFormEffect } from "@/lib/shared/components/form/ConfirmLeaveDirtyFormEffect"; import { CheckboxField } from "@/lib/shared/components/formFields/CheckboxField"; import { CheckboxGroupField } from "@/lib/shared/components/formFields/CheckboxGroupField"; @@ -66,17 +64,6 @@ export function DiagnosisForm({ procedureId, }); const upsertDiagnosis = useUpsertDiagnosis({ procedureId }); - const onCancelForm = useOnCancelForm<DiagnosisFormData>(); - - function handleCancel({ - dirty, - resetForm, - }: Pick<FormikProps<DiagnosisFormData>, "dirty" | "resetForm">) { - onCancelForm({ - dirty, - reset: resetForm, - }); - } function onSubmit(values: DiagnosisFormData) { const diagnosis = mapFormToApi(values); @@ -91,7 +78,7 @@ export function DiagnosisForm({ onSubmit={onSubmit} enableReinitialize > - {({ resetForm, dirty, values }) => ( + {({ values }) => ( <FormPlus sx={{ height: "100%" }}> <ConfirmLeaveDirtyFormEffect onSaveMutation={{ @@ -127,6 +114,7 @@ export function DiagnosisForm({ <TextareaFieldWithTextTemplates name="notes" label="Allgemeine Bemerkungen" + minRows={5} context={ApiTextTemplateContext.DiagnosisRemark} /> <CheckboxField @@ -136,10 +124,7 @@ export function DiagnosisForm({ </Stack> </SidecarSheet> </SidecarFormLayout> - <TabStickyBottomButtonBar - procedure={procedure} - onCancel={() => handleCancel({ dirty, resetForm })} - /> + <TabStickyBottomButtonBar /> </FormPlus> )} </Formik> @@ -152,6 +137,7 @@ function FindingsSection() { setFieldValue, } = useFormikContext<DiagnosisFormData>(); const icd10Sidebar = useIcd10Sidebar(); + const hasFindings = (findings?.length ?? 0) > 0; function handleClickIcd10Code() { icd10Sidebar.open({ @@ -190,11 +176,11 @@ function FindingsSection() { <HiddenIfDisabled> <Button sx={{ width: "fit-content" }} - startDecorator={<Edit />} + startDecorator={hasFindings ? <Edit /> : <Add />} variant="plain" onClick={handleClickIcd10Code} > - Befund bearbeiten + {hasFindings ? "Befund bearbeiten" : "Befund hinzufügen"} </Button> </HiddenIfDisabled> </SectionGrid> diff --git a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/diagnosis/helpers.ts b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/diagnosis/helpers.ts index 8e3a2e0d55151724989d8473f9f0209321af2995..e70edc5b0c3896d79bbd6ee0322b326d185e2d25 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/diagnosis/helpers.ts +++ b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/diagnosis/helpers.ts @@ -32,7 +32,7 @@ export interface MedicationFormData { } export const API_DIAGNOSIS_TEST_LABELS = { - [ApiTestType.WesternBlot]: "westernblot", + [ApiTestType.WesternBlot]: "Westernblot", [ApiTestType.P24]: "p24", [ApiTestType.Pcr]: "PCR", [ApiTestType.Other]: "Sonstiges", diff --git a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/examination/ExaminationStickyBottomButtonBar.tsx b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/examination/ExaminationStickyBottomButtonBar.tsx deleted file mode 100644 index 8acdff64f174c9655265dee8538b2f0f8016d7b4..0000000000000000000000000000000000000000 --- a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/examination/ExaminationStickyBottomButtonBar.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { SubmitButton } from "@eshg/lib-portal/components/buttons/SubmitButton"; -import { Button } from "@mui/joy"; - -import { StickyBottomButtonBar } from "@/lib/shared/components/buttons/StickyBottomButtonBar"; - -interface ExaminationStickyBottomButtonBarProps { - isSubmitting: boolean; - onClick: () => void; -} - -export function ExaminationStickyBottomButtonBar( - props: ExaminationStickyBottomButtonBarProps, -) { - const { isSubmitting, onClick } = props; - - return ( - <StickyBottomButtonBar - sx={{ padding: "0.75rem 1.5rem" }} - right={ - <> - <Button variant="plain" onClick={onClick}> - Abbrechen - </Button> - <SubmitButton submitting={isSubmitting}>Speichern</SubmitButton> - </> - } - ></StickyBottomButtonBar> - ); -} diff --git a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/examination/ExaminationTabNavPanel.tsx b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/examination/ExaminationTabNavPanel.tsx index 2fce4e2fa1aa3a3c54d882cd45c15bd7b69f37d0..5c93abb406adf62b1f37b500843ef2372332d928 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/examination/ExaminationTabNavPanel.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/examination/ExaminationTabNavPanel.tsx @@ -25,12 +25,12 @@ function buildNavItems(procedureId: string): NavItem[] { return [ { name: "Schnelltests", - href: routes.procedures.byId(procedureId).rapidTest, + href: routes.procedures.byId(procedureId).examination.rapidTest, icon: <LaboratoryTestOutlined />, }, { name: "Labortests", - href: routes.procedures.byId(procedureId).laboratoryTest, + href: routes.procedures.byId(procedureId).examination.laboratoryTest, icon: <BiotechOutlined />, }, ]; diff --git a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/examination/laboratoryTest/LaboratoryTestExamination.tsx b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/examination/laboratoryTest/LaboratoryTestExamination.tsx index cebaf74f027d7a9730451a3fdec47c8cbd8bccb2..c441ab459b238944d01f528bff55d458fe3825ad 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/examination/laboratoryTest/LaboratoryTestExamination.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/examination/laboratoryTest/LaboratoryTestExamination.tsx @@ -18,9 +18,8 @@ import { useUpsertLaboratoryTestOptions, } from "@/lib/businessModules/stiProtection/api/mutations/examination"; import { TextareaFieldWithTextTemplates } from "@/lib/businessModules/stiProtection/components/textTemplates/TextareaFieldWithTextTemplates"; -import { ExaminationStickyBottomButtonBar } from "@/lib/businessModules/stiProtection/features/procedures/examination/ExaminationStickyBottomButtonBar"; +import { TabStickyBottomButtonBar } from "@/lib/businessModules/stiProtection/features/procedures/TabStickyBottomButtonBar"; import { ExaminationTabNavPanel } from "@/lib/businessModules/stiProtection/features/procedures/examination/ExaminationTabNavPanel"; -import { useOnCancelForm } from "@/lib/businessModules/stiProtection/shared/helpers"; import { ConfirmLeaveDirtyFormEffect } from "@/lib/shared/components/form/ConfirmLeaveDirtyFormEffect"; import { CheckboxField } from "@/lib/shared/components/formFields/CheckboxField"; import { SidePanel } from "@/lib/shared/components/sidePanel/SidePanel"; @@ -51,7 +50,6 @@ export function LaboratoryTestExamination( procedureId, }); const upsertLaboratoryTests = useUpsertLaboratoryTest({ procedureId }); - const onCancel = useOnCancelForm<LaboratoryTestExaminationData>(); function onSubmit(values: LaboratoryTestExaminationData) { return upsertLaboratoryTests.mutateAsync({ @@ -69,7 +67,7 @@ export function LaboratoryTestExamination( onSubmit={onSubmit} enableReinitialize > - {({ resetForm, dirty, isSubmitting, values }) => ( + {({ values }) => ( <FormPlus sx={{ height: "100%", overflow: "hidden" }}> <ConfirmLeaveDirtyFormEffect onSaveMutation={{ @@ -205,10 +203,7 @@ export function LaboratoryTestExamination( </Grid> </Grid> </Box> - <ExaminationStickyBottomButtonBar - isSubmitting={isSubmitting} - onClick={() => onCancel({ dirty, reset: resetForm })} - /> + <TabStickyBottomButtonBar /> </FormPlus> )} </Formik> @@ -227,10 +222,10 @@ function ExaminationTabInfo() { aria-label={"Weitere Angaben zu den Labortests"} > <Stack paddingTop={1}> - <Typography>Allgemeine Bemerkung</Typography> <TextareaFieldWithTextTemplates name="generalRemarks" - minRows={4} + label="Allgemeine Bemerkungen" + minRows={5} context={ApiTextTemplateContext.LaboratoryTestsRemark} /> </Stack> diff --git a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/examination/rapidTest/RapidTestExamination.tsx b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/examination/rapidTest/RapidTestExamination.tsx index c62b38b01ff777d8806111611c2dfc9606e0b499..0f68c7355fffdf13a559d02b4d338fbc4651cc1c 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/examination/rapidTest/RapidTestExamination.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/examination/rapidTest/RapidTestExamination.tsx @@ -16,9 +16,8 @@ import { useUpsertRapidTests, } from "@/lib/businessModules/stiProtection/api/mutations/examination"; import { TextareaFieldWithTextTemplates } from "@/lib/businessModules/stiProtection/components/textTemplates/TextareaFieldWithTextTemplates"; -import { ExaminationStickyBottomButtonBar } from "@/lib/businessModules/stiProtection/features/procedures/examination/ExaminationStickyBottomButtonBar"; +import { TabStickyBottomButtonBar } from "@/lib/businessModules/stiProtection/features/procedures/TabStickyBottomButtonBar"; import { ExaminationTabNavPanel } from "@/lib/businessModules/stiProtection/features/procedures/examination/ExaminationTabNavPanel"; -import { useOnCancelForm } from "@/lib/businessModules/stiProtection/shared/helpers"; import { ConfirmLeaveDirtyFormEffect } from "@/lib/shared/components/form/ConfirmLeaveDirtyFormEffect"; import { CheckboxField } from "@/lib/shared/components/formFields/CheckboxField"; import { SidePanel } from "@/lib/shared/components/sidePanel/SidePanel"; @@ -45,7 +44,6 @@ export function RapidTestExamination(props: RapidTestExaminationProps) { const { procedureId, rapidTestExamination: rapidTests } = props; const upsertRapidTestOptions = useUpsertRapidTestOptions({ procedureId }); const upsertRapidTests = useUpsertRapidTests({ procedureId }); - const onCancel = useOnCancelForm<RapidTestExaminationData>(); function onSubmit(values: RapidTestExaminationData) { return upsertRapidTests.mutateAsync({ @@ -63,7 +61,7 @@ export function RapidTestExamination(props: RapidTestExaminationProps) { onSubmit={onSubmit} enableReinitialize > - {({ dirty, resetForm, isSubmitting, values }) => ( + {({ values }) => ( <FormPlus sx={{ height: "100%", overflow: "hidden" }}> <ConfirmLeaveDirtyFormEffect onSaveMutation={{ @@ -152,10 +150,7 @@ export function RapidTestExamination(props: RapidTestExaminationProps) { </Grid> </Grid> </Box> - <ExaminationStickyBottomButtonBar - isSubmitting={isSubmitting} - onClick={() => onCancel({ dirty, reset: resetForm })} - /> + <TabStickyBottomButtonBar /> </FormPlus> )} </Formik> @@ -174,10 +169,10 @@ function ExaminationTabInfo() { aria-label={"Weitere Angaben zu den Schnelltests"} > <Stack paddingTop={1}> - <Typography>Allgemeine Bemerkung</Typography> <TextareaFieldWithTextTemplates name="generalRemarks" - minRows={4} + label="Allgemeine Bemerkungen" + minRows={5} context={ApiTextTemplateContext.RapidTestsRemark} /> </Stack> diff --git a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/medicalHistory/MedicalHistoryForm.tsx b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/medicalHistory/MedicalHistoryForm.tsx index dc34fd52c458ef503375b1c26dbee68c435a4e26..287d53b3d09662c7444e98438efcc1273e9d2a4c 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/medicalHistory/MedicalHistoryForm.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/medicalHistory/MedicalHistoryForm.tsx @@ -5,6 +5,7 @@ "use client"; +import { BottomToolbar } from "@eshg/lib-employee-portal/components/toolbar/BottomToolbar"; import { SubmitButton } from "@eshg/lib-portal/components/buttons/SubmitButton"; import { FormPlus } from "@eshg/lib-portal/components/form/FormPlus"; import { HorizontalField } from "@eshg/lib-portal/components/formFields/HorizontalField"; @@ -33,9 +34,10 @@ import { SectionGrid } from "@/lib/businessModules/stiProtection/components/proc import { CONCERN_VALUES } from "@/lib/businessModules/stiProtection/shared/constants"; import { isProcedureOpen } from "@/lib/businessModules/stiProtection/shared/helpers"; import { routes } from "@/lib/businessModules/stiProtection/shared/routes"; -import { StickyBottomButtonBar } from "@/lib/shared/components/buttons/StickyBottomButtonBar"; +import { ButtonBar } from "@/lib/shared/components/buttons/ButtonBar"; import { ConfirmLeaveDirtyFormEffect } from "@/lib/shared/components/form/ConfirmLeaveDirtyFormEffect"; import { TextareaField } from "@/lib/shared/components/formFields/TextareaField"; +import { StickyBottomBox } from "@/lib/shared/components/layout/StickyBottomBox"; import { MedicalHistoryFormData, @@ -190,40 +192,46 @@ function MedicalHistoryStickyBottomButtonBar( const isOpenProcedure = isProcedureOpen(stiProcedure); return ( - <StickyBottomButtonBar - sx={{ padding: "0.75rem 1.5rem" }} - right={ - <> - <InternalLinkButton - href={routes.procedures.byId(stiProcedure.id).details} - variant="plain" - > - Abbrechen - </InternalLinkButton> - <SubmitButton submitting={isSubmitting} disabled={!isOpenProcedure}> - Speichern - </SubmitButton> - </> - } - left={ - <> - <PrintButton - label={"Anamnesebogen auf Deutsch herunterladen"} - text={"Druckvorlage herunterladen (DE)"} - onClick={() => - fetchMedicalHistoryDocument(stiProcedure.concern, "DE") - } - /> - <PrintButton - label={"Anamnesebogen auf Englisch herunterladen"} - text={"Druckvorlage herunterladen (EN)"} - onClick={() => - fetchMedicalHistoryDocument(stiProcedure.concern, "EN") - } - /> - </> - } - ></StickyBottomButtonBar> + <StickyBottomBox> + <BottomToolbar sx={{ padding: "0.75rem 1.5rem" }}> + <ButtonBar + right={ + <> + <InternalLinkButton + href={routes.procedures.byId(stiProcedure.id).details} + variant="plain" + > + Abbrechen + </InternalLinkButton> + <SubmitButton + submitting={isSubmitting} + disabled={!isOpenProcedure} + > + Speichern + </SubmitButton> + </> + } + left={ + <> + <PrintButton + label={"Anamnesebogen auf Deutsch herunterladen"} + text={"Druckvorlage herunterladen (DE)"} + onClick={() => + fetchMedicalHistoryDocument(stiProcedure.concern, "DE") + } + /> + <PrintButton + label={"Anamnesebogen auf Englisch herunterladen"} + text={"Druckvorlage herunterladen (EN)"} + onClick={() => + fetchMedicalHistoryDocument(stiProcedure.concern, "EN") + } + /> + </> + } + /> + </BottomToolbar> + </StickyBottomBox> ); } diff --git a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/medicalHistory/sections/General.tsx b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/medicalHistory/sections/General.tsx index a0b2254df8934ea120ffe27c5f1c65ca1832a6fb..10d57047d56fdae8c07729be6df31b400195ca36 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/medicalHistory/sections/General.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/medicalHistory/sections/General.tsx @@ -20,7 +20,7 @@ export function General({ isForSexWork }: { isForSexWork: boolean }) { const { values } = useFormikContext<MedicalHistoryFormData>(); return ( - <SectionGrid aria-label="Allgemein"> + <SectionGrid aria-label="Allgemein" columns="3fr 3fr"> <TextareaField name="general.examinationReason" label={"Grund für die heutige Beratung"} diff --git a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/medicalHistory/sections/SexualOrientationAndContact.tsx b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/medicalHistory/sections/SexualOrientationAndContact.tsx index 36ad830690b3e54f1223825f8569ea7be05711ad..15a2d8213d87cff81570fcad9b2b6283a4c7e90d 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/features/procedures/medicalHistory/sections/SexualOrientationAndContact.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/features/procedures/medicalHistory/sections/SexualOrientationAndContact.tsx @@ -55,7 +55,7 @@ export function SexualOrientationAndContact({ <CheckboxGroupField sx={{ gridColumnStart: 1, gridColumnEnd: 3 }} name="sexualOrientationAndContact.sexualContactFactors" - label={"Bisherige Sexparter:innen ist/hat"} + label={"Bisherige Sexpartner:innen ist/hat"} options={sexualContactFactorOptions} /> {isForSexWork ? ( diff --git a/employee-portal/src/lib/businessModules/stiProtection/shared/constants.ts b/employee-portal/src/lib/businessModules/stiProtection/shared/constants.ts index 1bb9ce0c4f30e462995e65a8c77077632d1b985d..f5f946e89e949ea7f6434861d7ac66711070be53 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/shared/constants.ts +++ b/employee-portal/src/lib/businessModules/stiProtection/shared/constants.ts @@ -16,6 +16,7 @@ import { ApiSexualOrientation, ApiTaskType, } from "@eshg/sti-protection-api"; +import { ChipProps } from "@mui/joy"; import { DefaultColorPalette } from "@mui/joy/styles/types"; export const procedureTypes = [ApiProcedureType.StiProtection]; @@ -30,6 +31,17 @@ export const PROCEDURE_STATUS_VALUES: EnumMap<ApiProcedureStatus> = { [ApiProcedureStatus.Open]: "Offen", }; +export const PROCEDURE_STATUS_COLORS: EnumMap< + ApiProcedureStatus, + ChipProps["color"] +> = { + [ApiProcedureStatus.Aborted]: "warning", + [ApiProcedureStatus.Closed]: "success", + [ApiProcedureStatus.Draft]: "neutral", + [ApiProcedureStatus.InProgress]: "primary", + [ApiProcedureStatus.Open]: "neutral", +}; + export const PROCEDURE_TYPES = [ApiProcedureType.StiProtection]; export const TASK_TYPES = [ApiTaskType.StiProtection]; @@ -40,6 +52,7 @@ export const systemProgressEntryTypeTitles: Record<string, string> = { LABORATORY_TEST_EXAMINATION_UPDATED: "Labortests aktualisiert", APPOINTMENT_REBOOKED: "Termin geändert", APPOINTMENT_CANCELLED: "Termin storniert", + APPOINTMENT_FINALIZED: "Termin abgeschlossen", MEDICAL_HISTORY_UPDATED: "Anamnesebogen aktualisiert", CONSULTATION_UPDATED: "Konsultation aktualisiert", DIAGNOSIS_UPDATED: "Diagnose aktualisiert", @@ -69,7 +82,8 @@ export const APPOINTMENT_TYPES: EnumMap<ApiAppointmentType> = { [ApiAppointmentType.RegularExamination]: "Regeluntersuchung", [ApiAppointmentType.SpecialNeeds]: "Besonderer Förderbedarf", [ApiAppointmentType.Vaccination]: "Impfung", - [ApiAppointmentType.OfficialMedicalService]: "Amtsärtzlicher Dienst", + [ApiAppointmentType.OfficialMedicalServiceShort]: "Kleine Untersuchung", + [ApiAppointmentType.OfficialMedicalServiceLong]: "Große Untersuchung", }; export const APPOINTMENT_STATUS: EnumMap<ApiAppointmentStatus> = { @@ -119,3 +133,9 @@ export const LAB_STATUS_VALUES: EnumMap<ApiLabStatus> = { [ApiLabStatus.InProgress]: "In Bearbeitung", [ApiLabStatus.Closed]: "Geschlossen", }; + +export const LAB_STATUS_COLORS: EnumMap<ApiLabStatus, ChipProps["color"]> = { + [ApiLabStatus.Open]: "neutral", + [ApiLabStatus.InProgress]: "primary", + [ApiLabStatus.Closed]: "success", +}; diff --git a/employee-portal/src/lib/businessModules/stiProtection/shared/routes.ts b/employee-portal/src/lib/businessModules/stiProtection/shared/routes.ts index 536722759b6b43e6aa5a17638b65d966af948dff..2f280a145424b9a42a10338e65e6a77990827d1d 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/shared/routes.ts +++ b/employee-portal/src/lib/businessModules/stiProtection/shared/routes.ts @@ -16,8 +16,11 @@ export const routes = { details: `${proceduresPath}/${procedureId}/details`, consultation: `${proceduresPath}/${procedureId}/consultation`, anamnesis: `${proceduresPath}/${procedureId}/anamnesis`, - rapidTest: `${proceduresPath}/${procedureId}/examination/rapid-test`, - laboratoryTest: `${proceduresPath}/${procedureId}/examination/laboratory-test`, + examination: { + index: `${proceduresPath}/${procedureId}/examination`, + rapidTest: `${proceduresPath}/${procedureId}/examination/rapid-test`, + laboratoryTest: `${proceduresPath}/${procedureId}/examination/laboratory-test`, + }, diagnosis: `${proceduresPath}/${procedureId}/diagnosis`, progressEntries: `${proceduresPath}/${procedureId}/progress-entries`, }), diff --git a/employee-portal/src/lib/businessModules/stiProtection/shared/sideNavigationItem.tsx b/employee-portal/src/lib/businessModules/stiProtection/shared/sideNavigationItem.tsx index b50b99f69955b4bbeb9455dd83773440aa4c28e8..75528b61624556ee11f8b0e23723331943094a10 100644 --- a/employee-portal/src/lib/businessModules/stiProtection/shared/sideNavigationItem.tsx +++ b/employee-portal/src/lib/businessModules/stiProtection/shared/sideNavigationItem.tsx @@ -5,11 +5,11 @@ import { ApiUserRole } from "@eshg/base-api"; import { hasUserRole } from "@eshg/lib-employee-portal/helpers/accessControl"; - import { SideNavigationSubItem, UseSideNavigationItemsResult, -} from "@/lib/baseModule/components/layout/sideNavigation/types"; +} from "@eshg/lib-employee-portal/types/sideNavigation"; + import { HivOutlined } from "@/lib/shared/components/icons/HivOutlined"; import { routes } from "./routes"; diff --git a/employee-portal/src/lib/businessModules/travelMedicine/components/appointmentBlocks/appointmentBlocksTable/AppointmentBlockGroupsTable.tsx b/employee-portal/src/lib/businessModules/travelMedicine/components/appointmentBlocks/appointmentBlocksTable/AppointmentBlockGroupsTable.tsx index 405d8ee44ad541b3a19e847f1fbf944ff167a4c0..44ef22e2719c36f4c956739bd49ba31f8c2b79fa 100644 --- a/employee-portal/src/lib/businessModules/travelMedicine/components/appointmentBlocks/appointmentBlocksTable/AppointmentBlockGroupsTable.tsx +++ b/employee-portal/src/lib/businessModules/travelMedicine/components/appointmentBlocks/appointmentBlocksTable/AppointmentBlockGroupsTable.tsx @@ -20,7 +20,7 @@ import { AppointmentBlockGroup, } from "@/lib/businessModules/travelMedicine/api/models/AppointmentBlock"; import { useGetAppointmentBlockGroupsQuery } from "@/lib/businessModules/travelMedicine/api/queries/appointmentBlocks"; -import { appointmentTypes } from "@/lib/businessModules/travelMedicine/shared/appointmentTypes"; +import { APPOINTMENT_TYPES } from "@/lib/businessModules/travelMedicine/components/appointmentTypes/translations"; import { routes } from "@/lib/businessModules/travelMedicine/shared/routes"; import { NoAppointmentBlocksAvailable } from "@/lib/shared/components/appointmentBlocks/NoAppointmentBlocksAvailable"; import { Pagination } from "@/lib/shared/components/pagination/Pagination"; @@ -55,7 +55,7 @@ const COLUMNS = [ columnHelper.accessor("type", { header: "Art", cell: (props) => - props.row.depth === 0 ? appointmentTypes[props.getValue()] : undefined, + props.row.depth === 0 ? APPOINTMENT_TYPES[props.getValue()] : undefined, enableSorting: false, }), columnHelper.accessor("start", { diff --git a/employee-portal/src/lib/businessModules/travelMedicine/components/appointmentBlocks/options.ts b/employee-portal/src/lib/businessModules/travelMedicine/components/appointmentBlocks/options.ts index 7948e41804966ec84fcd1ffdb2ed8c668ac37813..23363d560b8130054a848fcd79da02c969e23d4d 100644 --- a/employee-portal/src/lib/businessModules/travelMedicine/components/appointmentBlocks/options.ts +++ b/employee-portal/src/lib/businessModules/travelMedicine/components/appointmentBlocks/options.ts @@ -6,7 +6,7 @@ import { buildEnumOptions } from "@eshg/lib-portal/helpers/form"; import { ApiAppointmentType } from "@eshg/travel-medicine-api"; -import { appointmentTypes } from "@/lib/businessModules/travelMedicine/shared/appointmentTypes"; +import { APPOINTMENT_TYPES } from "@/lib/businessModules/travelMedicine/components/appointmentTypes/translations"; const SUPPORTED_APPOINTMENT_TYPES: string[] = [ ApiAppointmentType.Consultation, @@ -14,5 +14,5 @@ const SUPPORTED_APPOINTMENT_TYPES: string[] = [ ]; export const APPOINTMENT_TYPE_OPTIONS = buildEnumOptions<ApiAppointmentType>( - appointmentTypes, + APPOINTMENT_TYPES, ).filter((option) => SUPPORTED_APPOINTMENT_TYPES.includes(option.value)); diff --git a/employee-portal/src/lib/businessModules/travelMedicine/components/appointmentTypes/translations.ts b/employee-portal/src/lib/businessModules/travelMedicine/components/appointmentTypes/translations.ts index 829e53faaf78784cdc75fe2ca17f99cf59ff6544..5970fe236ab830d714e2129456ddc790d4eca0c0 100644 --- a/employee-portal/src/lib/businessModules/travelMedicine/components/appointmentTypes/translations.ts +++ b/employee-portal/src/lib/businessModules/travelMedicine/components/appointmentTypes/translations.ts @@ -20,7 +20,8 @@ export const APPOINTMENT_TYPES: EnumMap<ApiAppointmentType> = { [ApiAppointmentType.HivStiConsultation]: "HIV-STI-Beratung", [ApiAppointmentType.SexWork]: "Sexarbeit", [ApiAppointmentType.ResultsReview]: "Ergebnisbesprechung", - [ApiAppointmentType.OfficialMedicalService]: "Amtsärtzlicher Dienst", + [ApiAppointmentType.OfficialMedicalServiceShort]: "Kleine Untersuchung", + [ApiAppointmentType.OfficialMedicalServiceLong]: "Große Untersuchung", }; export const CREATED_BY_USER_TYPES: EnumMap<ApiCreatedByUserType> = { diff --git a/employee-portal/src/lib/businessModules/travelMedicine/components/vaccinationConsultations/VaccinationConsultationsOverviewTable.tsx b/employee-portal/src/lib/businessModules/travelMedicine/components/vaccinationConsultations/VaccinationConsultationsOverviewTable.tsx index 81926acb5b0238489456e19e35d0662e57be47b8..0b938d12b53f39e675bed6186d6b744839aa4f4d 100644 --- a/employee-portal/src/lib/businessModules/travelMedicine/components/vaccinationConsultations/VaccinationConsultationsOverviewTable.tsx +++ b/employee-portal/src/lib/businessModules/travelMedicine/components/vaccinationConsultations/VaccinationConsultationsOverviewTable.tsx @@ -21,6 +21,7 @@ import { FormControl, IconButton, Input, Select, Stack } from "@mui/joy"; import { useSuspenseQueries } from "@tanstack/react-query"; import { useEffect, useMemo, useState } from "react"; +import { NoEntries } from "@/lib/baseModule/components/NoEntries"; import { useGetAllProcedureAppointmentSummaries } from "@/lib/businessModules/travelMedicine/api/queries/vaccinationConsultation"; import { NewPerson } from "@/lib/businessModules/travelMedicine/components/vaccinationConsultations/new/NewPerson"; import { @@ -254,6 +255,9 @@ export function VaccinationConsultationsOverviewTable( focusColumnAccessorKey: "lastName", }} minWidth={1600} + noDataComponent={ + queryResult.isFetching ? () => undefined : () => <NoEntries /> + } /> </TableSheet> </TablePage> diff --git a/employee-portal/src/lib/businessModules/travelMedicine/shared/appointmentTypes.ts b/employee-portal/src/lib/businessModules/travelMedicine/shared/appointmentTypes.ts deleted file mode 100644 index 2469edc948449adbd18c5aa7138145a70b35a66c..0000000000000000000000000000000000000000 --- a/employee-portal/src/lib/businessModules/travelMedicine/shared/appointmentTypes.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright 2025 SCOOP Software GmbH, cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { EnumMap } from "@eshg/lib-portal/types/helpers"; -import { ApiAppointmentType } from "@eshg/travel-medicine-api"; - -export const appointmentTypes: EnumMap<ApiAppointmentType> = { - [ApiAppointmentType.Consultation]: "Beratung", - [ApiAppointmentType.Vaccination]: "Impfung", - [ApiAppointmentType.RegularExamination]: "Regeluntersuchung", - [ApiAppointmentType.CanChild]: "Kann-Kinder", - [ApiAppointmentType.EntryLevel]: "Eingangsstufe", - [ApiAppointmentType.SpecialNeeds]: "Besonderer Förderbedarf", - [ApiAppointmentType.ProofSubmission]: "Nachweisvorlage", - [ApiAppointmentType.HivStiConsultation]: "HIV-STI-Beratung", - [ApiAppointmentType.SexWork]: "Sexarbeit", - [ApiAppointmentType.ResultsReview]: "Ergebnisbesprechung", - [ApiAppointmentType.OfficialMedicalService]: "Amtsärtzlicher Dienst", -}; diff --git a/employee-portal/src/lib/businessModules/travelMedicine/shared/sideNavigationItem.tsx b/employee-portal/src/lib/businessModules/travelMedicine/shared/sideNavigationItem.tsx index 890850725f22049460a4a60dc29226217a7573f8..507ee4b1a733b95f9a6d3eeca38b22eeff173cd6 100644 --- a/employee-portal/src/lib/businessModules/travelMedicine/shared/sideNavigationItem.tsx +++ b/employee-portal/src/lib/businessModules/travelMedicine/shared/sideNavigationItem.tsx @@ -5,11 +5,11 @@ import { ApiBaseFeature, ApiUserRole } from "@eshg/base-api"; import { hasUserRole } from "@eshg/lib-employee-portal/helpers/accessControl"; +import { UseSideNavigationItemsResult } from "@eshg/lib-employee-portal/types/sideNavigation"; import { VaccinesOutlined } from "@mui/icons-material"; import { isPlainObject } from "remeda"; import { useIsNewFeatureEnabled } from "@/lib/baseModule/api/queries/feature"; -import { UseSideNavigationItemsResult } from "@/lib/baseModule/components/layout/sideNavigation/types"; import { routes } from "./routes"; diff --git a/employee-portal/src/lib/businessModules/travelMedicine/shared/templateEditor/TemplateEditorButtonBar.tsx b/employee-portal/src/lib/businessModules/travelMedicine/shared/templateEditor/TemplateEditorButtonBar.tsx index f709bfe6c4cdacbccb12d88a21a74d11c25a36b2..58d3a0f7d134e6e4195b24a651f00a29b5b5c2f0 100644 --- a/employee-portal/src/lib/businessModules/travelMedicine/shared/templateEditor/TemplateEditorButtonBar.tsx +++ b/employee-portal/src/lib/businessModules/travelMedicine/shared/templateEditor/TemplateEditorButtonBar.tsx @@ -3,10 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { BottomToolbar } from "@eshg/lib-employee-portal/components/toolbar/BottomToolbar"; import { SubmitButton } from "@eshg/lib-portal/components/buttons/SubmitButton"; import { InternalLinkButton } from "@eshg/lib-portal/components/navigation/InternalLinkButton"; -import { StickyBottomButtonBar } from "@/lib/shared/components/buttons/StickyBottomButtonBar"; +import { ButtonBar } from "@/lib/shared/components/buttons/ButtonBar"; +import { StickyBottomBox } from "@/lib/shared/components/layout/StickyBottomBox"; export function TemplateEditorButtonBar({ publish, @@ -22,29 +24,33 @@ export function TemplateEditorButtonBar({ disabled: boolean; }>) { return ( - <StickyBottomButtonBar - right={ - <> - <InternalLinkButton href={cancelRoute} variant="plain"> - Abbrechen - </InternalLinkButton> - <SubmitButton - submitting={isSubmitting} - onClick={save} - variant="outlined" - disabled={disabled} - > - Entwurf speichern - </SubmitButton> - <SubmitButton - submitting={isSubmitting} - onClick={publish} - disabled={disabled} - > - Veröffentlichen - </SubmitButton> - </> - } - ></StickyBottomButtonBar> + <StickyBottomBox> + <BottomToolbar> + <ButtonBar + right={ + <> + <InternalLinkButton href={cancelRoute} variant="plain"> + Abbrechen + </InternalLinkButton> + <SubmitButton + submitting={isSubmitting} + onClick={save} + variant="outlined" + disabled={disabled} + > + Entwurf speichern + </SubmitButton> + <SubmitButton + submitting={isSubmitting} + onClick={publish} + disabled={disabled} + > + Veröffentlichen + </SubmitButton> + </> + } + /> + </BottomToolbar> + </StickyBottomBox> ); } diff --git a/employee-portal/src/lib/shared/components/EmployeeSnackbar.tsx b/employee-portal/src/lib/shared/components/EmployeeSnackbar.tsx index 452da7c2ef3a6b7337b53f00ebc70440ec187ca8..0e888d9cedf4afb6b2a469503aba1227f9e2648e 100644 --- a/employee-portal/src/lib/shared/components/EmployeeSnackbar.tsx +++ b/employee-portal/src/lib/shared/components/EmployeeSnackbar.tsx @@ -5,11 +5,10 @@ "use client"; +import { useHeaderHeights } from "@eshg/lib-employee-portal/hooks/useHeaderHeights"; import { SnackbarComponentProps } from "@eshg/lib-portal/components/snackbar/SnackbarProvider"; import { Snackbar, Theme, styled } from "@mui/joy"; -import { useHeaderHeights } from "@/lib/baseModule/components/layout/useHeaderHeights"; - interface StyledSnackbarProps extends SnackbarComponentProps { headerHeightDesktop: string; headerHeightMobile: string; diff --git a/employee-portal/src/lib/shared/components/SidebarStepper/SidebarStepper.tsx b/employee-portal/src/lib/shared/components/SidebarStepper/SidebarStepper.tsx index 75f570aacb558426f8545268013104eefa5058f6..b8e3a2e5f880f7982769714bed97dea0c1007efc 100644 --- a/employee-portal/src/lib/shared/components/SidebarStepper/SidebarStepper.tsx +++ b/employee-portal/src/lib/shared/components/SidebarStepper/SidebarStepper.tsx @@ -169,7 +169,7 @@ export function SidebarStepper<TStepperFormModel extends FormikValues[]>({ <Stack gap={0.5}> <DialogTitle sx={{ color: "text.primary" }} - level="h3" + level="h2" component="h1" > {currentStepProps.title} diff --git a/employee-portal/src/lib/shared/components/archiving/ArchiveAdminView.tsx b/employee-portal/src/lib/shared/components/archiving/ArchiveAdminView.tsx index 7d78065a4a0497ad5a32176634151017aae13ea7..13705d87956e62baa24efb40045fc1db8fd786c3 100644 --- a/employee-portal/src/lib/shared/components/archiving/ArchiveAdminView.tsx +++ b/employee-portal/src/lib/shared/components/archiving/ArchiveAdminView.tsx @@ -4,6 +4,9 @@ */ import { ApiUserRole } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { UseBulkUpdateProceduresArchivingRelevance, @@ -12,9 +15,6 @@ import { import { UseGetRelevantArchivableProcedures } from "@/lib/shared/api/queries/archiving"; import { RestrictedPage } from "@/lib/shared/components/RestrictedPage"; import { ArchiveAdminTable } from "@/lib/shared/components/archiving/components/archiveAdminView/ArchiveAdminTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export interface ArchiveAdminViewProps { title: string; diff --git a/employee-portal/src/lib/shared/components/archiving/ArchiveView.tsx b/employee-portal/src/lib/shared/components/archiving/ArchiveView.tsx index 840527f944e7f5315c927f932740bcedbc702c86..441dcf1e8c52b8e0401385b5826b3f51bfb48b41 100644 --- a/employee-portal/src/lib/shared/components/archiving/ArchiveView.tsx +++ b/employee-portal/src/lib/shared/components/archiving/ArchiveView.tsx @@ -4,6 +4,9 @@ */ import { ApiUserRole } from "@eshg/base-api"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { ApiProcedureType } from "@eshg/lib-procedures-api"; import { UseBulkUpdateProceduresArchivingRelevance } from "@/lib/shared/api/mutations/archiving"; @@ -13,9 +16,6 @@ import { } from "@/lib/shared/api/queries/archiving"; import { RestrictedPage } from "@/lib/shared/components/RestrictedPage"; import { ArchiveTable } from "@/lib/shared/components/archiving/components/archiveView/ArchiveTable"; -import { MainContentLayout } from "@/lib/shared/components/layout/MainContentLayout"; -import { StickyToolbarLayout } from "@/lib/shared/components/layout/StickyToolbarLayout"; -import { Toolbar } from "@/lib/shared/components/layout/Toolbar"; export interface ArchiveViewProps { title: string; diff --git a/employee-portal/src/lib/shared/components/archiving/shared/sideNavigationItem.tsx b/employee-portal/src/lib/shared/components/archiving/shared/sideNavigationItem.tsx index d4539bbb041294736f5766da00b61dbe733637fc..226e5b22711560c12994b04e1640918b1caf42eb 100644 --- a/employee-portal/src/lib/shared/components/archiving/shared/sideNavigationItem.tsx +++ b/employee-portal/src/lib/shared/components/archiving/shared/sideNavigationItem.tsx @@ -5,10 +5,9 @@ import { ApiUserRole } from "@eshg/base-api"; import { hasUserRole } from "@eshg/lib-employee-portal/helpers/accessControl"; +import { SideNavigationItem } from "@eshg/lib-employee-portal/types/sideNavigation"; import { Inventory2Outlined } from "@mui/icons-material"; -import { SideNavigationItem } from "@/lib/baseModule/components/layout/sideNavigation/types"; - import { routes } from "./routes"; export const sideNavigationItems: SideNavigationItem[] = [ diff --git a/employee-portal/src/lib/shared/components/buttons/StickyBottomButtonBar.tsx b/employee-portal/src/lib/shared/components/buttons/StickyBottomButtonBar.tsx deleted file mode 100644 index 858556ad09c7e000e1eccde4bd3d54573cb90a07..0000000000000000000000000000000000000000 --- a/employee-portal/src/lib/shared/components/buttons/StickyBottomButtonBar.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: Apache-2.0 - */ - -"use client"; - -import { Sheet } from "@mui/joy"; -import { SxProps } from "@mui/joy/styles/types"; - -import { - ButtonBar, - ButtonBarProps, -} from "@/lib/shared/components/buttons/ButtonBar"; - -export interface StickyBottomButtonBarProps extends ButtonBarProps { - sx?: SxProps; -} - -/** Displays a {@link ButtonBar} sticky at the bottom of a page. */ -export function StickyBottomButtonBar( - props: Readonly<StickyBottomButtonBarProps>, -) { - const { sx: barSx, ...buttons } = props; - return ( - <Sheet - sx={{ - position: "sticky", - bottom: 0, - zIndex: (theme) => theme.zIndex.toolbar, - borderRadius: 0, - ...barSx, - }} - > - <ButtonBar {...buttons} /> - </Sheet> - ); -} diff --git a/employee-portal/src/lib/shared/components/facilitySidebar/FacilitySidebar.tsx b/employee-portal/src/lib/shared/components/facilitySidebar/FacilitySidebar.tsx index a40388b90631c392c90b13057dee55ffa639cd04..28c0361679072f3761a57d36265620375272c885 100644 --- a/employee-portal/src/lib/shared/components/facilitySidebar/FacilitySidebar.tsx +++ b/employee-portal/src/lib/shared/components/facilitySidebar/FacilitySidebar.tsx @@ -6,7 +6,7 @@ import { ApiGetReferenceFacilityResponse } from "@eshg/base-api"; import { LoadingIndicator } from "@eshg/lib-portal/components/LoadingIndicator"; import { FormikProps } from "formik"; -import { ComponentType, ReactNode, Ref } from "react"; +import { ComponentType, ReactNode } from "react"; import { isDefined } from "remeda"; import { FacilityDetailsSidebar } from "@/lib/shared/components/facilitySidebar/FacilityDetailsSidebar"; @@ -23,14 +23,11 @@ import { import { FacilitySearchResults } from "@/lib/shared/components/facilitySidebar/search/FacilitySearchResults"; import { useFacilitySidebarState } from "@/lib/shared/components/facilitySidebar/useFacilitySidebarState"; import { MultiFormButtonBar } from "@/lib/shared/components/form/MultiFormButtonBar"; -import { - SidebarFormHandle, - useSidebarFormHandle, -} from "@/lib/shared/components/form/SidebarForm"; -import { Sidebar } from "@/lib/shared/components/sidebar/Sidebar"; +import { useSidebarFormHandle } from "@/lib/shared/components/form/SidebarForm"; import { SidebarActions } from "@/lib/shared/components/sidebar/SidebarActions"; import { SidebarContent } from "@/lib/shared/components/sidebar/SidebarContent"; import { useResetAlertContextOnChange } from "@/lib/shared/hooks/useResetAlertContextOnChange"; +import { SidebarWithFormRefProps } from "@/lib/shared/hooks/useSidebarWithFormRef"; type OptionalSearchFormComponent<TSearchValues> = | { @@ -50,8 +47,6 @@ export type FacilitySidebarProps<TSearchValues> = { getInitialCreateInputs?: ( searchInputs: TSearchValues, ) => Partial<DefaultFacilityFormValues>; - - sidebarFormRef: Ref<SidebarFormHandle>; onCreateNew: (props: { searchInputs: FacilitySearchFormValues; createInputs: DefaultFacilityFormValues; @@ -60,31 +55,28 @@ export type FacilitySidebarProps<TSearchValues> = { searchInputs: FacilitySearchFormValues; facility: ApiGetReferenceFacilityResponse; }) => Promise<void>; - onClose: () => void; - open: boolean; -} & OptionalSearchFormComponent<TSearchValues>; +} & SidebarWithFormRefProps & + OptionalSearchFormComponent<TSearchValues>; export function FacilitySidebar< TSearchValues extends FacilitySearchFormValues = FacilitySearchFormValues, >(props: FacilitySidebarProps<TSearchValues>) { return ( - <Sidebar open={props.open} onClose={props.onClose}> - <EmbeddedFacilitySidebar - {...props} - searchFormComponent={ - isDefined(props.searchFormComponent) - ? (props.searchFormComponent as ComponentType< - FormikProps<TSearchValues> - >) - : undefined - } - initialSearchInputs={props.initialSearchInputs as TSearchValues} - /> - </Sidebar> + <EmbeddedFacilitySidebar + {...props} + searchFormComponent={ + isDefined(props.searchFormComponent) + ? (props.searchFormComponent as ComponentType< + FormikProps<TSearchValues> + >) + : undefined + } + initialSearchInputs={props.initialSearchInputs as TSearchValues} + /> ); } -export function EmbeddedFacilitySidebar< +function EmbeddedFacilitySidebar< TSearchValues extends FacilitySearchFormValues, >(props: FacilitySidebarProps<TSearchValues>) { const SearchFormComponent = (props.searchFormComponent ?? @@ -98,7 +90,7 @@ export function EmbeddedFacilitySidebar< dispatch({ type: "RESET" }); } - useSidebarFormHandle(props.sidebarFormRef, { + useSidebarFormHandle(props.formRef, { dirty: state.dirty, resetForm, }); @@ -106,7 +98,10 @@ export function EmbeddedFacilitySidebar< return ( <> {state.stage === "loading" && ( - <LoadingStage onCancel={props.onClose} title={props.title} /> + <LoadingStage + onCancel={() => props.onClose(false)} + title={props.title} + /> )} {state.stage === "search" && ( <FacilitySearchForm @@ -114,7 +109,8 @@ export function EmbeddedFacilitySidebar< loading={state.queryEnabled} initialValues={state.searchState} formFieldsComponent={SearchFormComponent} - onCancel={props.onClose} + sidebarFormRef={props.formRef} + onCancel={() => props.onClose(false)} onSearch={(inputs) => dispatch({ type: "SEARCH_START", @@ -128,11 +124,12 @@ export function EmbeddedFacilitySidebar< title={props.title} inputs={state.searchState} facilities={state.searchResult} + sidebarFormRef={props.formRef} header={props.searchResultHeaderComponent} onBack={ state.backEnabled ? () => dispatch({ type: "BACK" }) : undefined } - onCancel={props.onClose} + onCancel={() => props.onClose(false)} onSelect={(facility) => { dispatch({ type: "SELECTED", @@ -147,6 +144,7 @@ export function EmbeddedFacilitySidebar< title={props.title} submitLabel={props.submitLabel ?? "Vorgang anlegen"} searchInputs={state.searchState} + sidebarFormRef={props.formRef} initialValues={ (state.createState ?? isDefined(props.getInitialCreateInputs)) ? getInitialFacilityFormValues( @@ -157,7 +155,7 @@ export function EmbeddedFacilitySidebar< : undefined } mode={state.stage} - onCancel={props.onClose} + onCancel={() => props.onClose(false)} onBack={ state.backEnabled ? (values) => @@ -167,11 +165,12 @@ export function EmbeddedFacilitySidebar< }) : undefined } - onSubmit={(values) => { - return props.onCreateNew({ + onSubmit={async (values) => { + await props.onCreateNew({ searchInputs: state.searchState, createInputs: normalizeValues(values), }); + return props.onClose(true); }} /> )} @@ -181,15 +180,17 @@ export function EmbeddedFacilitySidebar< submitLabel={props.submitLabel ?? "Vorgang anlegen"} facility={state.selectedFacility} onSubmit={(facility) => - props.onSelect({ - searchInputs: state.searchState, - facility, - }) + props + .onSelect({ + searchInputs: state.searchState, + facility, + }) + .then(() => props.onClose(true)) } onBack={ state.backEnabled ? () => dispatch({ type: "BACK" }) : undefined } - onCancel={props.onClose} + onCancel={() => props.onClose(false)} /> )} </> diff --git a/employee-portal/src/lib/shared/components/facilitySidebar/useFacilitySidebarState.tsx b/employee-portal/src/lib/shared/components/facilitySidebar/useFacilitySidebarState.tsx index 6c1fec5740b164fe94dd1e1235c76a5e36aea6fa..8b4baac6d92b21707c302b1f898b14a1fe6484a7 100644 --- a/employee-portal/src/lib/shared/components/facilitySidebar/useFacilitySidebarState.tsx +++ b/employee-portal/src/lib/shared/components/facilitySidebar/useFacilitySidebarState.tsx @@ -228,7 +228,7 @@ export function useFacilitySidebarState< name: state.searchState.name, }, { - enabled: props.open && state.queryEnabled, + enabled: state.queryEnabled, }, ); diff --git a/employee-portal/src/lib/shared/components/layout/StickyBottomBox.tsx b/employee-portal/src/lib/shared/components/layout/StickyBottomBox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0e4221979d31dd068eb67582fa3843123287715a --- /dev/null +++ b/employee-portal/src/lib/shared/components/layout/StickyBottomBox.tsx @@ -0,0 +1,27 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +import { RequiresChildren } from "@eshg/lib-portal/types/react"; +import { Box } from "@mui/joy"; +import { SxProps } from "@mui/joy/styles/types"; + +interface StickyBottomBoxProps extends RequiresChildren { + sx?: SxProps; +} + +export function StickyBottomBox(props: StickyBottomBoxProps) { + return ( + <Box + sx={{ + position: "sticky", + bottom: 0, + zIndex: (theme) => theme.zIndex.toolbar, + ...props.sx, + }} + > + {props.children} + </Box> + ); +} diff --git a/employee-portal/src/lib/shared/components/page/SubPageHeader.tsx b/employee-portal/src/lib/shared/components/page/SubPageHeader.tsx index fba1578d6613ec2e96b2d08c692995617dc6a94f..f53a21e2892787c22f96ac1bac0884445cc3eb59 100644 --- a/employee-portal/src/lib/shared/components/page/SubPageHeader.tsx +++ b/employee-portal/src/lib/shared/components/page/SubPageHeader.tsx @@ -3,12 +3,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { useHeaderHeights } from "@eshg/lib-employee-portal/hooks/useHeaderHeights"; import { InternalLinkIconButton } from "@eshg/lib-portal/components/navigation/InternalLinkIconButton"; import ChevronLeft from "@mui/icons-material/ChevronLeft"; import { Divider, Sheet, Stack, Typography } from "@mui/joy"; -import { useHeaderHeights } from "@/lib/baseModule/components/layout/useHeaderHeights"; - export function SubPageHeader({ routeBack, header, diff --git a/employee-portal/src/lib/shared/components/personSidebar/PersonSidebar.tsx b/employee-portal/src/lib/shared/components/personSidebar/PersonSidebar.tsx index bfef83ce3d1c55245cd9102fdeba046f8399a65b..a7bc83c8fc8dab939d26d573a4cf2df8c4eb2fa1 100644 --- a/employee-portal/src/lib/shared/components/personSidebar/PersonSidebar.tsx +++ b/employee-portal/src/lib/shared/components/personSidebar/PersonSidebar.tsx @@ -5,11 +5,10 @@ import { ApiGetReferencePersonResponse } from "@eshg/base-api"; import { DefaultError, UseQueryOptions, useQuery } from "@tanstack/react-query"; -import { ComponentType, ReactNode, Ref, useEffect, useState } from "react"; +import { ComponentType, ReactNode, useEffect, useState } from "react"; import { isDefined } from "remeda"; import { useSearchReferencePersonsQuery } from "@/lib/baseModule/api/queries/persons"; -import { SidebarFormHandle } from "@/lib/shared/components/form/SidebarForm"; import { DefaultPersonForm, DefaultPersonFormValues, @@ -31,6 +30,7 @@ import { SearchPersonSidebar, } from "@/lib/shared/components/personSidebar/search/SearchPersonSidebar"; import { useResetAlertContextOnChange } from "@/lib/shared/hooks/useResetAlertContextOnChange"; +import { SidebarWithFormRefProps } from "@/lib/shared/hooks/useSidebarWithFormRef"; import { PersonDetailsSidebar } from "./PersonDetailsSidebar"; import { AssociatedProceduresSearchResult } from "./search/AssociatedProceduresSearchResult"; @@ -77,8 +77,8 @@ export type PersonSidebarProps< TCreateValues extends PersonFormValues = DefaultPersonFormValues, TProcedure = unknown, > = SearchFormProps<TSearchValues> & + SidebarWithFormRefProps & CreateFormProps<TSearchValues, TCreateValues> & { - onCancel: () => void; onBack?: () => void; onCreate: (props: { searchInputs: TSearchValues; @@ -88,7 +88,6 @@ export type PersonSidebarProps< searchInputs: TSearchValues; person: ApiGetReferencePersonResponse; }) => Promise<void>; - sidebarFormRef: Ref<SidebarFormHandle>; title: string; submitLabel: string; addressRequired?: boolean; @@ -200,8 +199,8 @@ export function PersonSidebar< return ( <SearchPersonSidebar<TSearchValues> searchFormTitle={props.title} - sidebarFormRef={props.sidebarFormRef} - onCancel={props.onCancel} + sidebarFormRef={props.formRef} + onCancel={() => props.onClose(false)} onBack={props.onBack} initialValues={state.searchState} searchFormComponent={SearchFormComponent} @@ -224,9 +223,9 @@ export function PersonSidebar< return ( <PersonSearchResults title={props.title} - sidebarFormRef={props.sidebarFormRef} + sidebarFormRef={props.formRef} loadingAssociatedProcedures={getAssociatedProceduresQuery.isLoading} - onCancel={props.onCancel} + onCancel={() => props.onClose(false)} onBack={() => setState((previous) => ({ ...previous, mode: "search" }))} inputs={state.searchState} persons={state.searchResult} @@ -258,8 +257,8 @@ export function PersonSidebar< title={props.title} subtitle={"Person anlegen"} submitLabel={props.submitLabel} - sidebarFormRef={props.sidebarFormRef} - onCancel={props.onCancel} + sidebarFormRef={props.formRef} + onCancel={() => props.onClose(false)} onBack={() => setState((previous) => ({ ...previous, @@ -267,10 +266,12 @@ export function PersonSidebar< })) } onSubmit={async (values) => - await props.onCreate({ - searchInputs: state.searchState, - createInputs: values, - }) + await props + .onCreate({ + searchInputs: state.searchState, + createInputs: values, + }) + .then(() => props.onClose(true)) } addressRequired={props.addressRequired} initialValues={state.createState} @@ -287,7 +288,7 @@ export function PersonSidebar< ) { return ( <AssociatedProceduresSearchResult<TProcedure> - onCancel={props.onCancel} + onCancel={() => props.onClose(false)} onBack={() => setState((previous) => ({ ...previous, mode: "search_results" })) } @@ -302,7 +303,7 @@ export function PersonSidebar< title={props.title} person={state.selectedPerson} submitLabel={props.submitLabel} - onCancel={props.onCancel} + onCancel={() => props.onClose(false)} onBack={() => setState((previous) => ({ ...previous, @@ -310,10 +311,12 @@ export function PersonSidebar< })) } onSubmit={(person) => - props.onSelect({ - searchInputs: state.searchState, - person: person, - }) + props + .onSelect({ + searchInputs: state.searchState, + person: person, + }) + .then(() => props.onClose(true)) } /> ); diff --git a/employee-portal/src/lib/shared/components/procedures/inbox/InboxProceduresPage.tsx b/employee-portal/src/lib/shared/components/procedures/inbox/InboxProceduresPage.tsx index 3b132ec6370a2ffe0f591d0ed5c212300eebffc8..186f10a4cdbe61becde61a95a5334ea3100c2160 100644 --- a/employee-portal/src/lib/shared/components/procedures/inbox/InboxProceduresPage.tsx +++ b/employee-portal/src/lib/shared/components/procedures/inbox/InboxProceduresPage.tsx @@ -5,15 +5,15 @@ "use client"; +import { MainContentLayout } from "@eshg/lib-employee-portal/components/layout/MainContentLayout"; +import { StickyToolbarLayout } from "@eshg/lib-employee-portal/components/layout/StickyToolbarLayout"; +import { Toolbar } from "@eshg/lib-employee-portal/components/toolbar/Toolbar"; import { ApiProcedureType } from "@eshg/lib-procedures-api"; import { UseFetchInboxProcedure, UseFetchInboxProcedures, } from "@/lib/shared/api/queries/inboxProcedures"; -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 { InboxProceduresTable } from "@/lib/shared/components/procedures/inbox/InboxProceduresTable"; import { UseCloseInboxProcedure } from "@/lib/shared/components/procedures/inbox/mutations/useCloseInboxProcedureStatusTemplate"; diff --git a/employee-portal/src/lib/shared/components/sidebar/Sidebar.tsx b/employee-portal/src/lib/shared/components/sidebar/Sidebar.tsx index 890038cbcb43fde11cb4c4cf1b8c05a9fb767bc3..907bf9b7547c509bae8734be95e87c16c897f65c 100644 --- a/employee-portal/src/lib/shared/components/sidebar/Sidebar.tsx +++ b/employee-portal/src/lib/shared/components/sidebar/Sidebar.tsx @@ -3,12 +3,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { useHeaderHeights } from "@eshg/lib-employee-portal/hooks/useHeaderHeights"; import { useResetAlertContext } from "@eshg/lib-portal/errorHandling/AlertContext"; import { Drawer, DrawerProps, ModalClose, Stack, ZIndex } from "@mui/joy"; import { PropsWithChildren } from "react"; -import { useHeaderHeights } from "@/lib/baseModule/components/layout/useHeaderHeights"; - export const sidebarPadding = 3; export type SidebarProps = PropsWithChildren< diff --git a/employee-portal/src/lib/shared/components/tabNavigationToolbar/TabNavigationToolbar.tsx b/employee-portal/src/lib/shared/components/tabNavigationToolbar/TabNavigationToolbar.tsx index cf2ff273520a70abf7f11d1f4a27f935dbd51b0d..4bb2788ba25d9ee29bfbd0e980c111b610379cd7 100644 --- a/employee-portal/src/lib/shared/components/tabNavigationToolbar/TabNavigationToolbar.tsx +++ b/employee-portal/src/lib/shared/components/tabNavigationToolbar/TabNavigationToolbar.tsx @@ -38,6 +38,7 @@ export function TabNavigationToolbar(props: TabNavigationToolbarProps) { sx={{ padding: 0, borderRadius: 0, + borderLeft: 0, }} data-testid="tabNavigationToolbar" > diff --git a/employee-portal/src/lib/shared/components/table/TableRow.tsx b/employee-portal/src/lib/shared/components/table/TableRow.tsx index f59b0dc056691e6c0ed6aeb31ce2a3afcdf5c257..05859125aa0b491003776935650c6530022d3c1b 100644 --- a/employee-portal/src/lib/shared/components/table/TableRow.tsx +++ b/employee-portal/src/lib/shared/components/table/TableRow.tsx @@ -176,7 +176,6 @@ export function TableRow<TData>({ }) .map((cell) => { const canNavigate = cellCanNavigate(cell); - return ( <StyledCell colSpan={ diff --git a/employee-portal/tsconfig.json b/employee-portal/tsconfig.json index 4e7f0e549e237380a57a184c979409f9279d6f3e..952ad8293b776d996c7836d853809caa57d31c45 100644 --- a/employee-portal/tsconfig.json +++ b/employee-portal/tsconfig.json @@ -4,7 +4,8 @@ "outDir": "./build/dist", "paths": { "@/*": ["./src/*"] - } + }, + "types": ["@eshg/lib-employee-portal/types/theme"] }, "include": [ "staticSvgImage.d.ts", @@ -12,6 +13,7 @@ "next-env.d.ts", ".next/types/**/*.ts", "vitest.config.ts", + "vitest-setup.ts", "eslint.config.js", "src/**/*.ts", "src/**/*.tsx", diff --git a/employee-portal/vitest-setup.ts b/employee-portal/vitest-setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..09f73711263d640ca662a97cf3760761420c085c --- /dev/null +++ b/employee-portal/vitest-setup.ts @@ -0,0 +1,6 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import "@eshg/lib-vitest/extend-expect"; diff --git a/employee-portal/vitest.config.ts b/employee-portal/vitest.config.ts index a5b0b7bc8b07c480eae5a8e8a3041e2b78a31466..06a2f515bd375d09c29779e94069daedf77a28fa 100644 --- a/employee-portal/vitest.config.ts +++ b/employee-portal/vitest.config.ts @@ -3,9 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -// eslint-disable-next-line no-restricted-imports -import { defineConfig } from "vitest/config"; +import { mergeConfig } from "vitest/config"; +// eslint-disable-next-line no-restricted-imports import { VITEST_BASE_CONFIG } from "../config/vitest.base"; -export default defineConfig(VITEST_BASE_CONFIG); +export default mergeConfig(VITEST_BASE_CONFIG, { + test: { + setupFiles: ["vitest-setup.ts"], + }, +}); diff --git a/lib-portal/gradleDependencies.json b/lib-portal/gradleDependencies.json index 890ca1a3395b9fcd515a8b18ecd48927d4f9a384..eb6fab77cc614822f44868e037c8139bcd20a98d 100644 --- a/lib-portal/gradleDependencies.json +++ b/lib-portal/gradleDependencies.json @@ -1,3 +1,3 @@ { - "dependencies": [":base-api", ":medical-registry-api"] + "dependencies": [":base-api", ":lib-vitest", ":medical-registry-api"] } diff --git a/lib-portal/package.json b/lib-portal/package.json index e2570187d4950b3f3f00ec3fad2c06f070bfc875..b8a3f77dbe4c3c0c9c4a1dd67edc26cf3178288b 100644 --- a/lib-portal/package.json +++ b/lib-portal/package.json @@ -23,6 +23,7 @@ "valibot": "catalog:common" }, "devDependencies": { + "@eshg/lib-vitest": "workspace:*", "@eslint/compat": "catalog:eslint", "@eslint/eslintrc": "catalog:eslint", "@tanstack/eslint-plugin-query": "catalog:common", diff --git a/lib-portal/src/helpers/guards.ts b/lib-portal/src/helpers/guards.ts index 58e9c8e33bc7c06cdb69efbd67a6e1bd84061c97..a3c2ec9ce1a517818118b89fab5bcd745ef5939b 100644 --- a/lib-portal/src/helpers/guards.ts +++ b/lib-portal/src/helpers/guards.ts @@ -30,3 +30,9 @@ export function isDict(value: unknown): value is Record<string, unknown> { export function isBlankString(value: string): value is string { return value.trim() === ""; } + +export function isNonEmptyArray<T>( + value: T[] | null | undefined, +): value is T[] { + return Array.isArray(value) && value.length > 0; +} diff --git a/packages/dental/src/api/models/ChildExamination.ts b/packages/dental/src/api/models/ChildExamination.ts index f7b4883eb6a71c5578d240f3cac61d7390a224d5..1b10ea3945a2b6df2ce24cd45109e3e472b33a1f 100644 --- a/packages/dental/src/api/models/ChildExamination.ts +++ b/packages/dental/src/api/models/ChildExamination.ts @@ -4,6 +4,7 @@ */ import { + ApiFluoridationConsent, ApiGender, ApiProphylaxisSessionChildExamination, } from "@eshg/dental-api"; @@ -21,7 +22,8 @@ export interface ChildExamination { readonly dateOfBirth: Date; readonly groupName: string; readonly gender?: ApiGender; - readonly fluoridationConsentGiven?: boolean; + readonly currentFluoridationConsent?: ApiFluoridationConsent; + readonly allFluoridationConsents: ApiFluoridationConsent[]; readonly status: ExaminationStatus; readonly result?: ExaminationResult; readonly note?: string; @@ -39,7 +41,8 @@ export function mapChildExamination( dateOfBirth: response.dateOfBirth, groupName: response.groupName, gender: response.gender, - fluoridationConsentGiven: response.fluoridationConsentGiven, + currentFluoridationConsent: response.allFluoridationConsents[0], + allFluoridationConsents: response.allFluoridationConsents, status: mapToExaminationStatus(response.result), result: mapOptional(response.result, mapExaminationResult), note: response.note, diff --git a/packages/dental/src/api/models/ExaminationResult.ts b/packages/dental/src/api/models/ExaminationResult.ts index 97c7680e8c770ed1275da31e1dc24a185dce7429..cd7fcd93321e2257c459eae57ed17fa1ba0e6986 100644 --- a/packages/dental/src/api/models/ExaminationResult.ts +++ b/packages/dental/src/api/models/ExaminationResult.ts @@ -5,6 +5,7 @@ import { ApiAbsenceExaminationResult, + ApiDentitionType, ApiExaminationResult, ApiFluoridationExaminationResult, ApiOralHygieneStatus, @@ -30,6 +31,7 @@ export interface ScreeningExaminationResult { readonly type: "screening"; readonly oralHygieneStatus?: ApiOralHygieneStatus; readonly fluorideVarnishApplied?: boolean; + readonly dentitionType: ApiDentitionType; readonly toothDiagnoses: ToothDiagnoses; } @@ -69,6 +71,7 @@ function mapScreeningExaminationResult( type: "screening", oralHygieneStatus: response.oralHygieneStatus, fluorideVarnishApplied: response.fluorideVarnishApplied, + dentitionType: response.dentitionType, toothDiagnoses: mapToObj( response.toothDiagnoses, (toothDiagnosisResponse) => [ @@ -92,17 +95,18 @@ type FieldFunctionMap<T> = { [K in keyof T]-?: (value: T[K]) => boolean; }; +function isUndefined<T>(data: T | undefined) { + return data === undefined; +} + const screeningResultEmptinessChecks: FieldFunctionMap<ScreeningExaminationResult> = { type: (value) => { return value === "screening"; }, - oralHygieneStatus: (value) => { - return value === undefined; - }, - fluorideVarnishApplied: (value) => { - return value === undefined; - }, + oralHygieneStatus: isUndefined, + fluorideVarnishApplied: isUndefined, + dentitionType: () => true, toothDiagnoses: (value) => { return Object.keys(value).length === 0; }, @@ -113,9 +117,7 @@ const fluoridationResultEmptinessChecks: FieldFunctionMap<FluoridationExaminatio type: (value) => { return value === "fluoridation"; }, - fluorideVarnishApplied: (value) => { - return value === undefined; - }, + fluorideVarnishApplied: isUndefined, }; function isEmptyResult<T extends ExaminationResult>( diff --git a/packages/dental/src/api/models/ProphylaxisSessionDetails.ts b/packages/dental/src/api/models/ProphylaxisSessionDetails.ts index 11c47633633625266a3b9e2660c44597b93099df..ba8bc83a8d2b277f5ab3ebd04acb7252e6a8fd8a 100644 --- a/packages/dental/src/api/models/ProphylaxisSessionDetails.ts +++ b/packages/dental/src/api/models/ProphylaxisSessionDetails.ts @@ -4,6 +4,7 @@ */ import { + ApiDentitionType, ApiPerformingPerson, ApiProphylaxisSessionDetails, } from "@eshg/dental-api"; @@ -16,6 +17,7 @@ import { export interface ProphylaxisSessionDetails extends ProphylaxisSession { version: number; + dentitionType?: ApiDentitionType; participants: ChildExamination[]; dentists: ApiPerformingPerson[]; zfas: ApiPerformingPerson[]; @@ -27,6 +29,7 @@ export function mapProphylaxisSessionDetails( return { ...response, ...mapProphylaxisSession(response), + dentitionType: response.dentitionType, participants: response.participants.map(mapChildExamination), version: response.version, }; diff --git a/packages/dental/tsconfig.json b/packages/dental/tsconfig.json index 5fde44c2e55357d00edfaa8092b86f56f6730a13..64606a8bd5bbce68942ba53acd760fce87e25ae3 100644 --- a/packages/dental/tsconfig.json +++ b/packages/dental/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "paths": { "@/*": ["./src/*"] - } + }, + "types": ["@eshg/lib-employee-portal/types/theme"] } } diff --git a/packages/lib-employee-portal/README.md b/packages/lib-employee-portal/README.md new file mode 100644 index 0000000000000000000000000000000000000000..1d99101b366a780356d85825fc8171495de2a786 --- /dev/null +++ b/packages/lib-employee-portal/README.md @@ -0,0 +1,15 @@ +# lib-employee-portal + +## Registering theme types + +We are using several extensions to the standard Joy UI theme, e.g. additional breakpoints. + +To register the extended theme types in your package, add the following types to your package's `tsconfig.json`: + +```json +{ + "compilerOptions": { + "types": ["@eshg/lib-employee-portal/types/theme"] + } +} +``` diff --git a/employee-portal/src/lib/shared/components/layout/MainContentLayout.tsx b/packages/lib-employee-portal/src/components/layout/MainContentLayout.tsx similarity index 100% rename from employee-portal/src/lib/shared/components/layout/MainContentLayout.tsx rename to packages/lib-employee-portal/src/components/layout/MainContentLayout.tsx diff --git a/employee-portal/src/lib/shared/components/layout/StickyToolbarLayout.tsx b/packages/lib-employee-portal/src/components/layout/StickyToolbarLayout.tsx similarity index 85% rename from employee-portal/src/lib/shared/components/layout/StickyToolbarLayout.tsx rename to packages/lib-employee-portal/src/components/layout/StickyToolbarLayout.tsx index 7ce5e9439151b38c3b1c0a1d7f0b12a48bc79f41..693d9e6cac6744af565061102a843d8da6c347b1 100644 --- a/employee-portal/src/lib/shared/components/layout/StickyToolbarLayout.tsx +++ b/packages/lib-employee-portal/src/components/layout/StickyToolbarLayout.tsx @@ -8,11 +8,12 @@ import { Box } from "@mui/joy"; import { ReactNode } from "react"; -import { useHeaderHeights } from "@/lib/baseModule/components/layout/useHeaderHeights"; +import { useHeaderHeights } from "@/hooks/useHeaderHeights"; export interface StickyToolbarLayoutProps { children: ReactNode; toolbar: ReactNode; + bottomToolbar?: ReactNode; } /** @@ -53,6 +54,16 @@ export function StickyToolbarLayout(props: StickyToolbarLayoutProps) { > {props.children} </Box> + + <Box + sx={{ + position: "sticky", + zIndex: (theme) => theme.zIndex.toolbar, + bottom: 0, + }} + > + {props.bottomToolbar} + </Box> </> ); } diff --git a/packages/lib-employee-portal/src/components/toolbar/BottomToolbar.tsx b/packages/lib-employee-portal/src/components/toolbar/BottomToolbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b79146b2e00d3e9ac08be575d623cb36aed32e78 --- /dev/null +++ b/packages/lib-employee-portal/src/components/toolbar/BottomToolbar.tsx @@ -0,0 +1,22 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +import { RequiresChildren } from "@eshg/lib-portal/types/react"; +import { Sheet } from "@mui/joy"; +import { SxProps } from "@mui/joy/styles/types"; + +interface BottomToolbarProps extends RequiresChildren { + sx?: SxProps; +} + +export function BottomToolbar(props: BottomToolbarProps) { + return ( + <Sheet + sx={{ borderRadius: 0, borderWidth: 0, borderTopWidth: 1, ...props.sx }} + > + {props.children} + </Sheet> + ); +} diff --git a/employee-portal/src/lib/shared/components/layout/Toolbar.tsx b/packages/lib-employee-portal/src/components/toolbar/Toolbar.tsx similarity index 88% rename from employee-portal/src/lib/shared/components/layout/Toolbar.tsx rename to packages/lib-employee-portal/src/components/toolbar/Toolbar.tsx index 3ef8b3e79e099231bee52b107b72866611bc582a..8575d01d49e7f3038cfa1ffae4199b217127b0be 100644 --- a/employee-portal/src/lib/shared/components/layout/Toolbar.tsx +++ b/packages/lib-employee-portal/src/components/toolbar/Toolbar.tsx @@ -7,10 +7,10 @@ import { Row } from "@eshg/lib-portal/components/Row"; import { InternalLinkButton } from "@eshg/lib-portal/components/navigation/InternalLinkButton"; -import ChevronLeft from "@mui/icons-material/ChevronLeft"; +import { ChevronLeft } from "@mui/icons-material"; import { Sheet, Typography } from "@mui/joy"; -import { simpleToolbarHeight } from "@/lib/baseModule/components/layout/sizes"; +import { useLayoutConfig } from "@/contexts/layoutConfig"; export interface ToolbarProps { title: string; @@ -18,6 +18,8 @@ export interface ToolbarProps { } export function Toolbar({ title, backHref }: ToolbarProps) { + const { simpleToolbarHeight } = useLayoutConfig(); + return ( <Row sx={{ gap: 0 }}> {backHref && ( @@ -42,7 +44,6 @@ export function Toolbar({ title, backHref }: ToolbarProps) { borderRadius: 0, borderWidth: 0, borderBottomWidth: 1, - borderLeftWidth: 1, height: simpleToolbarHeight, flex: 1, }} diff --git a/packages/lib-employee-portal/src/contexts/layoutConfig.tsx b/packages/lib-employee-portal/src/contexts/layoutConfig.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9d5169db3fa3a8e2d5da4aa31757878732c02e0a --- /dev/null +++ b/packages/lib-employee-portal/src/contexts/layoutConfig.tsx @@ -0,0 +1,39 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +"use client"; + +import { RequiresChildren } from "@eshg/lib-portal/types/react"; +import { createContext, useContext } from "react"; + +export interface LayoutConfig { + appBarHeightMobile: string; + appBarHeightDesktop: string; + simpleToolbarHeight: string; +} + +const LayoutConfigContext = createContext<LayoutConfig | null>(null); + +interface LayoutConfigProviderProps extends RequiresChildren { + config: LayoutConfig; +} + +export function LayoutConfigProvider(props: LayoutConfigProviderProps) { + return ( + <LayoutConfigContext.Provider value={props.config}> + {props.children} + </LayoutConfigContext.Provider> + ); +} + +export function useLayoutConfig(): LayoutConfig { + const layoutConfig = useContext(LayoutConfigContext); + + if (layoutConfig === null) { + throw new Error("Missing LayoutConfigContext"); + } + + return layoutConfig; +} diff --git a/employee-portal/src/lib/baseModule/components/layout/useHeaderHeights.tsx b/packages/lib-employee-portal/src/hooks/useHeaderHeights.tsx similarity index 72% rename from employee-portal/src/lib/baseModule/components/layout/useHeaderHeights.tsx rename to packages/lib-employee-portal/src/hooks/useHeaderHeights.tsx index 7b429450ff7230847a67c02c1aea2b4f18829df8..6f35ffd9d626b578cc366cc7387534b4ef5d7cfd 100644 --- a/employee-portal/src/lib/baseModule/components/layout/useHeaderHeights.tsx +++ b/packages/lib-employee-portal/src/hooks/useHeaderHeights.tsx @@ -1,17 +1,15 @@ /** * Copyright 2025 cronn GmbH - * SPDX-License-Identifier: AGPL-3.0-only + * SPDX-License-Identifier: Apache-2.0 */ import { useEnvironmentIndicatorHeight } from "@eshg/lib-portal/components/EnvironmentIndicator"; -import { - appBarHeightDesktop, - appBarHeightMobile, -} from "@/lib/baseModule/components/layout/sizes"; +import { useLayoutConfig } from "@/contexts/layoutConfig"; export function useHeaderHeights() { const environmentIndicatorHeight = useEnvironmentIndicatorHeight(); + const { appBarHeightMobile, appBarHeightDesktop } = useLayoutConfig(); return { headerHeightMobile: `calc(${environmentIndicatorHeight} + ${appBarHeightMobile})`, diff --git a/packages/lib-employee-portal/src/types/theme.ts b/packages/lib-employee-portal/src/types/theme.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3c628bf2706750b6da3bfc0311b4b916f59523e --- /dev/null +++ b/packages/lib-employee-portal/src/types/theme.ts @@ -0,0 +1,34 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FontSize } from "@mui/joy/styles"; + +declare module "@mui/joy/styles" { + interface BreakpointOverrides { + xxs: true; + xxl: true; + } +} + +declare module "@mui/joy/styles/types/zIndex" { + interface ZIndexOverrides { + toolbar: true; + sidebar: true; + sideNavigation: true; + header: true; + } +} + +declare module "@mui/joy/ToggleButtonGroup" { + interface ToggleButtonGroupPropsVariantOverrides { + tabs: true; + } +} + +type FontSizeOverrides = { [_k in keyof FontSize]: true }; +declare module "@mui/joy/SvgIcon" { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface SvgIconPropsSizeOverrides extends FontSizeOverrides {} +} diff --git a/packages/lib-vitest/README.md b/packages/lib-vitest/README.md new file mode 100644 index 0000000000000000000000000000000000000000..fb70f4124afe693f4bac1b572886f24569c4b838 --- /dev/null +++ b/packages/lib-vitest/README.md @@ -0,0 +1,33 @@ +# lib-vitest + +## Using Custom Matchers in your subproject + +### 1. Add `lib-vitest` as a dev dependency, e.g. using + +`./gradlew :dental:addWorkspaceDependency -Pdev -Ppackage=lib-vitest` + +### 2. Add `vitest-setup.ts` in your subproject, importing `extend-expect` + +```ts +import "@eshg/lib-vitest/extend-expect"; +``` + +### 3. Add `vitest-setup.ts` to your `tsconfig.json` + +```json +{ + "include": [ + "vitest-setup.ts" + ] +} +``` + +### 4. Add setup file to `vitest.config.ts` + +```ts +export default mergeConfig(VITEST_BASE_CONFIG, { + test: { + setupFiles: ["vitest-setup.ts"], + }, +}); +``` diff --git a/packages/lib-vitest/README_LICENSE.adoc b/packages/lib-vitest/README_LICENSE.adoc new file mode 100644 index 0000000000000000000000000000000000000000..87f2419aaf60835f287ea4b3d058bd1a2cd01097 --- /dev/null +++ b/packages/lib-vitest/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/packages/lib-vitest/build.gradle b/packages/lib-vitest/build.gradle new file mode 100644 index 0000000000000000000000000000000000000000..85cfad1d06023db3df1dc9052936c80742123a25 --- /dev/null +++ b/packages/lib-vitest/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'lib-package' +} diff --git a/packages/lib-vitest/buildscript-gradle.lockfile b/packages/lib-vitest/buildscript-gradle.lockfile new file mode 100644 index 0000000000000000000000000000000000000000..0d156738b209adc7660610a8d35b7e87ebdb8211 --- /dev/null +++ b/packages/lib-vitest/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/packages/lib-vitest/eslint.config.js b/packages/lib-vitest/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..cdf693271cd0f8202f58d3a00fd2551f9fe13af2 --- /dev/null +++ b/packages/lib-vitest/eslint.config.js @@ -0,0 +1,8 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +import { eslintNextConfigs } from "../../config/eslint.next.js"; + +export default eslintNextConfigs.lib; diff --git a/packages/lib-vitest/package.json b/packages/lib-vitest/package.json new file mode 100644 index 0000000000000000000000000000000000000000..fa9308d2ea5a115217aa012470643bde28771f03 --- /dev/null +++ b/packages/lib-vitest/package.json @@ -0,0 +1,37 @@ +{ + "name": "@eshg/lib-vitest", + "version": "0.0.1", + "type": "module", + "private": true, + "exports": { + ".": { + "types": "./build/types/src/index.d.ts", + "import": "./build/lib/index.js" + }, + "./extend-expect": { + "types": "./build/types/src/extend-expect.d.ts", + "import": "./build/lib/extend-expect.js" + } + }, + "devDependencies": { + "@eslint/compat": "catalog:eslint", + "@eslint/eslintrc": "catalog:eslint", + "@trivago/prettier-plugin-sort-imports": "catalog:prettier", + "@types/node": "catalog:common", + "@vitest/coverage-istanbul": "catalog:vitest", + "eslint": "catalog:eslint", + "eslint-plugin-import": "catalog:eslint", + "eslint-config-prettier": "catalog:eslint", + "eslint-plugin-unused-imports": "catalog:eslint", + "eslint-plugin-promise": "catalog:eslint", + "prettier": "catalog:prettier", + "resolve-tspaths": "catalog:common", + "tsup": "catalog:common", + "typescript": "catalog:common", + "typescript-eslint": "catalog:eslint", + "vite-tsconfig-paths": "catalog:vitest" + }, + "peerDependencies": { + "vitest": "catalog:vitest" + } +} diff --git a/packages/lib-vitest/src/extend-expect.ts b/packages/lib-vitest/src/extend-expect.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca75d32c1ce66ff59c4964fd2f8cc454239a56af --- /dev/null +++ b/packages/lib-vitest/src/extend-expect.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/no-empty-object-type, @typescript-eslint/no-explicit-any */ +import { expect } from "vitest"; + +import * as matchers from "./matchers"; +import type { CustomMatchers } from "./matchers"; + +expect.extend(matchers); + +declare module "vitest" { + interface Assertion<T = any> extends CustomMatchers<T> {} + interface AsymmetricMatchersContaining extends CustomMatchers {} +} diff --git a/lib-portal/src/helpers/test.ts b/packages/lib-vitest/src/helpers/doWithFakeTimers.ts similarity index 100% rename from lib-portal/src/helpers/test.ts rename to packages/lib-vitest/src/helpers/doWithFakeTimers.ts diff --git a/packages/lib-vitest/src/index.ts b/packages/lib-vitest/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb4ba33a2d77131a738698887017e11f050442d7 --- /dev/null +++ b/packages/lib-vitest/src/index.ts @@ -0,0 +1,6 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +export { doWithFakeTimers } from "./helpers/doWithFakeTimers"; diff --git a/packages/lib-vitest/src/matchers/index.ts b/packages/lib-vitest/src/matchers/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa8a2a6d55e99818fd01876017499c6509442a34 --- /dev/null +++ b/packages/lib-vitest/src/matchers/index.ts @@ -0,0 +1,15 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + MatchValidationFileOptions, + toMatchValidationFile, +} from "./toMatchValidationFile/toMatchValidationFile"; + +export { toMatchValidationFile }; + +export interface CustomMatchers<R = unknown> { + toMatchValidationFile: (options?: MatchValidationFileOptions) => R; +} diff --git a/packages/lib-vitest/src/matchers/toMatchValidationFile/guards.ts b/packages/lib-vitest/src/matchers/toMatchValidationFile/guards.ts new file mode 100644 index 0000000000000000000000000000000000000000..21e1e96023f9ff0f0365b6147af164a5858e2e4c --- /dev/null +++ b/packages/lib-vitest/src/matchers/toMatchValidationFile/guards.ts @@ -0,0 +1,12 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +export function isArray(value: unknown): value is unknown[] { + return Array.isArray(value); +} + +export function isObject(value: unknown): value is object { + return typeof value == "object" && value !== null; +} diff --git a/packages/lib-vitest/src/matchers/toMatchValidationFile/normalizer.ts b/packages/lib-vitest/src/matchers/toMatchValidationFile/normalizer.ts new file mode 100644 index 0000000000000000000000000000000000000000..f66ac4ebc2e45eced423faa8721da6d6bf5a7529 --- /dev/null +++ b/packages/lib-vitest/src/matchers/toMatchValidationFile/normalizer.ts @@ -0,0 +1,136 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isArray, isObject } from "./guards"; + +type JsonValue = + | Record<string, unknown> + | unknown[] + | string + | number + | boolean + | null; + +interface NormalizerOptions { + maskUndefinedObjectProperties: boolean; +} + +export function normalize(value: unknown, options: NormalizerOptions): string { + const jsonValue = normalizeValue(value, options); + return stringifyJsonValue(jsonValue); +} + +function normalizeValue(value: unknown, options: NormalizerOptions): JsonValue { + if (value === undefined) { + return maskedValue("undefined"); + } + + if (Number.isNaN(value)) { + return maskedValue("NaN"); + } + + if (value === Infinity) { + return maskedValue("Infinity"); + } + + if ( + typeof value === "boolean" || + typeof value === "number" || + value === null + ) { + return value; + } + + if (value instanceof Date) { + return maskedValue(value.toISOString()); + } + + if (value instanceof Promise) { + return maskedValue("Promise"); + } + + if (typeof value === "function") { + return maskedValue("Function"); + } + + if (typeof value === "string") { + return value.trim(); + } + + if (typeof value === "symbol") { + return maskedValue(value.toString()); + } + + if (isArray(value)) { + return normalizeArray(value, options); + } + + if (isObject(value)) { + return normalizeObject(value, options); + } + + throw new Error(`Missing normalizer for value of type ${typeof value}`); +} + +function normalizeArray( + value: unknown[], + options: NormalizerOptions, +): JsonValue { + return value.map((item) => normalizeValue(item, options)); +} + +function normalizeObject(value: object, options: NormalizerOptions): JsonValue { + if (value instanceof Set) { + return normalizeArray([...value.values()], options); + } + + if (value instanceof Map) { + const mapAsObject = normalizeMap(value, options); + return normalizeObject(mapAsObject, options); + } + + const normalizedObject: Record<string, unknown> = {}; + + for (const [key, propertyValue] of Object.entries(value)) { + if (propertyValue === undefined && !options.maskUndefinedObjectProperties) { + continue; + } + + const normalizedKey = normalize(key, options); + normalizedObject[normalizedKey] = normalizeValue(propertyValue, options); + } + + return normalizedObject; +} + +function normalizeMap( + value: Map<unknown, unknown>, + options: NormalizerOptions, +): Record<string, unknown> { + return value.entries().reduce( + (object, [key, value]) => { + const normalizedKey = normalize(key, options); + object[normalizedKey] = value; + return object; + }, + {} as Record<string, unknown>, + ); +} + +function maskedValue(value: string) { + return `[${value}]`; +} + +function stringifyJsonValue(jsonValue: JsonValue): string { + if (jsonValue === null) { + return maskedValue("null"); + } + + if (typeof jsonValue === "object") { + return JSON.stringify(jsonValue, undefined, 2); + } + + return jsonValue.toString().trim(); +} diff --git a/packages/lib-vitest/src/matchers/toMatchValidationFile/toMatchValidationFile.ts b/packages/lib-vitest/src/matchers/toMatchValidationFile/toMatchValidationFile.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca2f35c484a31111144931c202777370b148fbe9 --- /dev/null +++ b/packages/lib-vitest/src/matchers/toMatchValidationFile/toMatchValidationFile.ts @@ -0,0 +1,111 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ExpectationResult, MatcherState } from "@vitest/expect"; +import * as fs from "fs"; +import * as path from "path"; +import "vitest"; + +import { isObject } from "./guards"; +import { normalize } from "./normalizer"; + +const TEST_PATH_SEPARATOR = " > "; +const MISSING_FILE_BANNER = "===== missing file ====="; +const OUTPUT_FOLDER = "data/test/output"; +const VALIDATION_FOLDER = "data/test/validation"; + +export interface MatchValidationFileOptions { + suffix?: string; + maskUndefinedObjectProperties?: boolean; +} + +export function toMatchValidationFile( + this: MatcherState, + received: unknown, + options: MatchValidationFileOptions = {}, +): ExpectationResult { + const { currentTestName, testPath, equals, isNot } = this; + + if (currentTestName === undefined) { + throw new Error("Missing test name"); + } + + if (testPath === undefined) { + throw new Error("Missing test path"); + } + + if (isNot) { + throw new Error("Matcher negation is not supported"); + } + + const testNames = currentTestName + .split(TEST_PATH_SEPARATOR) + .map(normalizeTestName); + const suffix = options.suffix !== undefined ? `_${options.suffix}` : ""; + const fileExtension = getFileExtension(received); + + const testName = testNames.pop(); + const absoluteTestNamePath = path.join(testPath, ...testNames); + const relativeTestNamePath = path.relative("src", absoluteTestNamePath); + const fileName = `${testName}${suffix}.${fileExtension}`; + + const outputFolder = `${OUTPUT_FOLDER}/${relativeTestNamePath}`; + const actualFile = `${outputFolder}/${fileName}`; + + const validationFolder = `${VALIDATION_FOLDER}/${relativeTestNamePath}`; + const validationFile = `${validationFolder}/${fileName}`; + + mkdir(outputFolder); + mkdir(validationFolder); + + const normalizedReceived = normalize(received, { + maskUndefinedObjectProperties: + options.maskUndefinedObjectProperties ?? false, + }); + const actual = `${normalizedReceived}\n`; + + if (!fs.existsSync(validationFile)) { + writeFile(validationFile, `${MISSING_FILE_BANNER}\n${actual}`); + } + writeFile(actualFile, actual); + + const storedActual = readFile(actualFile); + const storedValidation = readFile(validationFile); + + return { + pass: equals(storedActual, storedValidation, [], true), + message: () => "Actual value does not match validation file", + actual: storedActual, + expected: storedValidation, + }; +} + +function normalizeTestName(name: string): string { + return name + .replaceAll(/[ .:]/g, "_") + .replaceAll(/'(\w+)'/g, "$1") + .replaceAll(/'/g, "_") + .replaceAll(/,/g, ""); +} + +function getFileExtension(value: unknown): string { + if (isObject(value)) { + return "json"; + } + + return "txt"; +} + +function mkdir(path: string): void { + fs.mkdirSync(path, { recursive: true }); +} + +function readFile(path: string): string { + return fs.readFileSync(path, { encoding: "utf8" }); +} + +function writeFile(file: string, data: string): void { + fs.writeFileSync(file, data, { encoding: "utf8" }); +} diff --git a/packages/lib-vitest/tsconfig.json b/packages/lib-vitest/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..5fde44c2e55357d00edfaa8092b86f56f6730a13 --- /dev/null +++ b/packages/lib-vitest/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../config/tsconfig.lib.json", + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/packages/lib-vitest/tsup.config.ts b/packages/lib-vitest/tsup.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..d211b7bb6e97cfddc7bb8e7051be6dc51700968f --- /dev/null +++ b/packages/lib-vitest/tsup.config.ts @@ -0,0 +1,11 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +import { defineLibConfig } from "../../config/tsup.base"; + +export default defineLibConfig( + ["src/index.ts", "src/extend-expect.ts"], + "node", +); diff --git a/packages/lib-vitest/vitest.config.ts b/packages/lib-vitest/vitest.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..e52a7a96f1890b8592c6289e794fd1221e8cd228 --- /dev/null +++ b/packages/lib-vitest/vitest.config.ts @@ -0,0 +1,11 @@ +/** + * Copyright 2025 cronn GmbH + * SPDX-License-Identifier: Apache-2.0 + */ + +// eslint-disable-next-line no-restricted-imports +import { defineConfig } from "vitest/config"; + +import { VITEST_BASE_CONFIG } from "../../config/vitest.base"; + +export default defineConfig(VITEST_BASE_CONFIG); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3439126d9cd7ab95d1e47c941150199852db723e..04b3039f5cca4bc58c60f7bc94a27b39255aa3d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,7 +249,7 @@ importers: version: 3.4.2 tsup: specifier: catalog:common - version: 8.3.6(postcss@8.4.38)(tsx@4.19.2)(typescript@5.7.3) + version: 8.3.6(postcss@8.4.38)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0) typescript: specifier: catalog:common version: 5.7.3 @@ -425,6 +425,9 @@ importers: '@eshg/school-entry-api': specifier: workspace:* version: link:../packages/school-entry-api + '@eshg/sti-protection-api': + specifier: workspace:* + version: link:../packages/sti-protection-api '@eshg/travel-medicine-api': specifier: workspace:* version: link:../packages/travel-medicine-api @@ -501,6 +504,9 @@ importers: specifier: catalog:common version: 0.42.1(typescript@5.7.3) devDependencies: + '@eshg/lib-vitest': + specifier: workspace:* + version: link:../packages/lib-vitest '@eslint/compat': specifier: catalog:eslint version: 1.2.6(eslint@9.19.0) @@ -684,12 +690,15 @@ importers: vitest: specifier: catalog:vitest version: 3.0.4(@types/debug@4.1.12)(@types/node@22.13.0)(terser@5.36.0) + yaml: + specifier: 2.7.0 + version: 2.7.0 employee-portal: dependencies: '@ducanh2912/next-pwa': specifier: 10.2.9 - version: 10.2.9(@types/babel__core@7.20.5)(next@14.2.14(@babel/core@7.26.0)(@playwright/test@1.50.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(webpack@5.92.1) + version: 10.2.9(@types/babel__core@7.20.5)(next@14.2.14(@babel/core@7.26.0)(@playwright/test@1.50.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(webpack@5.92.1(esbuild@0.24.2)) '@emotion/react': specifier: catalog:joy version: 11.14.0(@types/react@18.3.12)(react@18.3.1) @@ -823,8 +832,8 @@ importers: specifier: 2.1.2 version: 2.1.2 matrix-js-sdk: - specifier: 34.13.0 - version: 34.13.0 + specifier: 36.2.0 + version: 36.2.0 next: specifier: catalog:next version: 14.2.14(@babel/core@7.26.0)(@playwright/test@1.50.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -837,6 +846,9 @@ importers: react-error-boundary: specifier: catalog:common version: 5.0.0(react@18.3.1) + react-idle-timer: + specifier: ^5.7.2 + version: 5.7.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-infinite-scroll-hook: specifier: 5.0.1 version: 5.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -868,6 +880,9 @@ importers: specifier: catalog:common version: 5.0.3(@types/react@18.3.12)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1)) devDependencies: + '@eshg/lib-vitest': + specifier: workspace:* + version: link:../packages/lib-vitest '@eslint/compat': specifier: catalog:eslint version: 1.2.6(eslint@9.19.0) @@ -980,6 +995,9 @@ importers: specifier: catalog:common version: 0.42.1(typescript@5.7.3) devDependencies: + '@eshg/lib-vitest': + specifier: workspace:* + version: link:../packages/lib-vitest '@eslint/compat': specifier: catalog:eslint version: 1.2.6(eslint@9.19.0) @@ -1135,7 +1153,7 @@ importers: version: 0.8.23(typescript@5.7.3) tsup: specifier: catalog:common - version: 8.3.6(postcss@8.4.38)(tsx@4.19.2)(typescript@5.7.3) + version: 8.3.6(postcss@8.4.38)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0) typescript: specifier: catalog:common version: 5.7.3 @@ -1238,7 +1256,7 @@ importers: version: 0.8.23(typescript@5.7.3) tsup: specifier: catalog:common - version: 8.3.6(postcss@8.4.38)(tsx@4.19.2)(typescript@5.7.3) + version: 8.3.6(postcss@8.4.38)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0) typescript: specifier: catalog:common version: 5.7.3 @@ -1256,6 +1274,61 @@ importers: packages/lib-statistics-api: {} + packages/lib-vitest: + dependencies: + vitest: + specifier: catalog:vitest + version: 3.0.4(@types/debug@4.1.12)(@types/node@22.13.0)(terser@5.36.0) + devDependencies: + '@eslint/compat': + specifier: catalog:eslint + version: 1.2.6(eslint@9.19.0) + '@eslint/eslintrc': + specifier: catalog:eslint + version: 3.2.0 + '@trivago/prettier-plugin-sort-imports': + specifier: catalog:prettier + version: 5.2.2(prettier@3.4.2) + '@types/node': + specifier: catalog:common + version: 22.13.0 + '@vitest/coverage-istanbul': + specifier: catalog:vitest + version: 3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.13.0)(terser@5.36.0)) + eslint: + specifier: catalog:eslint + version: 9.19.0 + eslint-config-prettier: + specifier: catalog:eslint + version: 10.0.1(eslint@9.19.0) + eslint-plugin-import: + specifier: catalog:eslint + version: 2.31.0(@typescript-eslint/parser@8.22.0(eslint@9.19.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.3)(eslint@9.19.0) + eslint-plugin-promise: + specifier: catalog:eslint + version: 7.2.1(eslint@9.19.0) + eslint-plugin-unused-imports: + specifier: catalog:eslint + version: 4.1.4(@typescript-eslint/eslint-plugin@8.22.0(@typescript-eslint/parser@8.22.0(eslint@9.19.0)(typescript@5.7.3))(eslint@9.19.0)(typescript@5.7.3))(eslint@9.19.0) + prettier: + specifier: catalog:prettier + version: 3.4.2 + resolve-tspaths: + specifier: catalog:common + version: 0.8.23(typescript@5.7.3) + tsup: + specifier: catalog:common + version: 8.3.6(postcss@8.4.38)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0) + typescript: + specifier: catalog:common + version: 5.7.3 + typescript-eslint: + specifier: catalog:eslint + version: 8.22.0(eslint@9.19.0)(typescript@5.7.3) + vite-tsconfig-paths: + specifier: catalog:vitest + version: 5.1.4(typescript@5.7.3)(vite@5.3.1(@types/node@22.13.0)(terser@5.36.0)) + packages/measles-protection-api: {} packages/medical-registry-api: {} @@ -2715,9 +2788,9 @@ packages: resolution: {integrity: sha512-dOC64QbdYkAp8tv8rwdyerQMovV1cE58C/t8LeBGzvFYrJf+aCOA30qKXu8hNu7fRVvP8AWJ3u45X3lAZFhSYA==} engines: {node: '>=18'} - '@matrix-org/matrix-sdk-crypto-wasm@9.1.0': - resolution: {integrity: sha512-CtPoNcoRW6ehwxpRQAksG3tR+NJ7k4DV02nMFYTDwQtie1V4R8OTY77BjEIs97NOblhtS26jU8m1lWsOBEz0Og==} - engines: {node: '>= 10'} + '@matrix-org/matrix-sdk-crypto-wasm@13.0.0': + resolution: {integrity: sha512-2gtpjnxL42sdJAgkwitpMMI4cw7Gcjf5sW0MXoe+OAlXPlxIzyM+06F5JJ8ENvBeHkuV2RqtFIRrh8i90HLsMw==} + engines: {node: '>= 18'} '@matrix-org/olm@3.2.15': resolution: {integrity: sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==} @@ -2728,6 +2801,7 @@ packages: '@mui/base@5.0.0-beta.40-0': resolution: {integrity: sha512-hG3atoDUxlvEy+0mqdMpWd04wca8HKr2IHjW/fAjlkCHQolSLazhZM46vnHjOf15M4ESu25mV/3PgjczyjVM4w==} engines: {node: '>=12.0.0'} + deprecated: This package has been replaced by @base-ui-components/react peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5913,8 +5987,8 @@ packages: matrix-events-sdk@0.0.1: resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} - matrix-js-sdk@34.13.0: - resolution: {integrity: sha512-AAU8ZdCawca+7ucQfdcC3LA85OtCTV7QeqcjvKt/ZZhU3xL9VoawuoRQ+4R6H8KZnqyJmT4j7bdeC0jG4qcqLg==} + matrix-js-sdk@36.2.0: + resolution: {integrity: sha512-pP44qfqLA9tiJjx5YjxBPPkUmNsA2G0nb04ZUTuPbtQFmfK5cEQgIpvoCq69oqU6aulufeYpxJmd9yNffOvF9g==} engines: {node: '>=20.0.0'} matrix-widget-api@1.10.0: @@ -6805,6 +6879,12 @@ packages: react-native: optional: true + react-idle-timer@5.7.2: + resolution: {integrity: sha512-+BaPfc7XEUU5JFkwZCx6fO1bLVK+RBlFH+iY4X34urvIzZiZINP6v2orePx3E6pAztJGE7t4DzvL7if2SL/0GQ==} + peerDependencies: + react: '>=16' + react-dom: '>=16' + react-infinite-scroll-hook@5.0.1: resolution: {integrity: sha512-fn6+8BAZLQ9C1fvO5kPicGjDR2WHxK7rP4aaSWuaJkvtoJjYuudGJ9wjgPox7dghKm5Xj9cpKFycM86/wAJ3ig==} peerDependencies: @@ -8180,6 +8260,11 @@ packages: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} + yaml@2.7.0: + resolution: {integrity: sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==} + engines: {node: '>= 14'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -9110,15 +9195,15 @@ snapshots: '@drauu/core@0.4.2': {} - '@ducanh2912/next-pwa@10.2.9(@types/babel__core@7.20.5)(next@14.2.14(@babel/core@7.26.0)(@playwright/test@1.50.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(webpack@5.92.1)': + '@ducanh2912/next-pwa@10.2.9(@types/babel__core@7.20.5)(next@14.2.14(@babel/core@7.26.0)(@playwright/test@1.50.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(webpack@5.92.1(esbuild@0.24.2))': dependencies: fast-glob: 3.3.2 next: 14.2.14(@babel/core@7.26.0)(@playwright/test@1.50.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) semver: 7.6.3 - webpack: 5.92.1 + webpack: 5.92.1(esbuild@0.24.2) workbox-build: 7.1.1(@types/babel__core@7.20.5) workbox-core: 7.1.0 - workbox-webpack-plugin: 7.1.0(@types/babel__core@7.20.5)(webpack@5.92.1) + workbox-webpack-plugin: 7.1.0(@types/babel__core@7.20.5)(webpack@5.92.1(esbuild@0.24.2)) workbox-window: 7.1.0 transitivePeerDependencies: - '@types/babel__core' @@ -9625,7 +9710,7 @@ snapshots: url-join: 5.0.0 url-template: 3.1.1 - '@matrix-org/matrix-sdk-crypto-wasm@9.1.0': {} + '@matrix-org/matrix-sdk-crypto-wasm@13.0.0': {} '@matrix-org/olm@3.2.15': {} @@ -13453,10 +13538,10 @@ snapshots: matrix-events-sdk@0.0.1: {} - matrix-js-sdk@34.13.0: + matrix-js-sdk@36.2.0: dependencies: '@babel/runtime': 7.25.6 - '@matrix-org/matrix-sdk-crypto-wasm': 9.1.0 + '@matrix-org/matrix-sdk-crypto-wasm': 13.0.0 '@matrix-org/olm': 3.2.15 another-json: 0.2.0 bs58: 6.0.0 @@ -14422,12 +14507,13 @@ snapshots: possible-typed-array-names@1.0.0: {} - postcss-load-config@6.0.1(postcss@8.4.38)(tsx@4.19.2): + postcss-load-config@6.0.1(postcss@8.4.38)(tsx@4.19.2)(yaml@2.7.0): dependencies: lilconfig: 3.1.3 optionalDependencies: postcss: 8.4.38 tsx: 4.19.2 + yaml: 2.7.0 postcss-selector-parser@6.1.2: dependencies: @@ -14606,6 +14692,11 @@ snapshots: optionalDependencies: react-dom: 18.3.1(react@18.3.1) + react-idle-timer@5.7.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-infinite-scroll-hook@5.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -15397,14 +15488,16 @@ snapshots: type-fest: 0.16.0 unique-string: 2.0.0 - terser-webpack-plugin@5.3.10(webpack@5.92.1): + terser-webpack-plugin@5.3.10(esbuild@0.24.2)(webpack@5.92.1(esbuild@0.24.2)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.2 terser: 5.36.0 - webpack: 5.92.1 + webpack: 5.92.1(esbuild@0.24.2) + optionalDependencies: + esbuild: 0.24.2 terser@5.36.0: dependencies: @@ -15501,7 +15594,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.3.6(postcss@8.4.38)(tsx@4.19.2)(typescript@5.7.3): + tsup@8.3.6(postcss@8.4.38)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0): dependencies: bundle-require: 5.1.0(esbuild@0.24.2) cac: 6.7.14 @@ -15511,7 +15604,7 @@ snapshots: esbuild: 0.24.2 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.4.38)(tsx@4.19.2) + postcss-load-config: 6.0.1(postcss@8.4.38)(tsx@4.19.2)(yaml@2.7.0) resolve-from: 5.0.0 rollup: 4.30.1 source-map: 0.8.0-beta.0 @@ -15910,7 +16003,7 @@ snapshots: webpack-sources@3.2.3: {} - webpack@5.92.1: + webpack@5.92.1(esbuild@0.24.2): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.6 @@ -15933,7 +16026,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(webpack@5.92.1) + terser-webpack-plugin: 5.3.10(esbuild@0.24.2)(webpack@5.92.1(esbuild@0.24.2)) watchpack: 2.4.2 webpack-sources: 3.2.3 transitivePeerDependencies: @@ -16182,12 +16275,12 @@ snapshots: workbox-sw@7.1.0: {} - workbox-webpack-plugin@7.1.0(@types/babel__core@7.20.5)(webpack@5.92.1): + workbox-webpack-plugin@7.1.0(@types/babel__core@7.20.5)(webpack@5.92.1(esbuild@0.24.2)): dependencies: fast-json-stable-stringify: 2.1.0 pretty-bytes: 5.6.0 upath: 1.2.0 - webpack: 5.92.1 + webpack: 5.92.1(esbuild@0.24.2) webpack-sources: 1.4.3 workbox-build: 7.1.0(@types/babel__core@7.20.5) transitivePeerDependencies: @@ -16263,6 +16356,8 @@ snapshots: yaml@1.10.2: {} + yaml@2.7.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/reverse-proxy/citizen-portal.conf b/reverse-proxy/citizen-portal.conf index 45feb0aef975f97ee475c17c0d5b25b32b578df2..5144baf36b096f56a46b141364e62c064a540795 100644 --- a/reverse-proxy/citizen-portal.conf +++ b/reverse-proxy/citizen-portal.conf @@ -151,6 +151,11 @@ server { proxy_pass http://host.docker.internal:8097/feature-toggles; } + # No authorization required for the sti protection public endpoints + location /api/sti-protection/citizen/public { + proxy_pass http://host.docker.internal:8095/citizen/public; + } + # handle disabled backends as 404 # note: all /api/ routes must appear before this location /api/ { @@ -184,7 +189,7 @@ server { proxy_pass http://host.docker.internal:3001; } - location ~ ^/((en|de)/)?(einschulungsuntersuchung/.+|(?:unternehmen/)?mein-bereich/.+|impfberatung/meine-termine(?:/.+)?|sexuellegesundheit/.+/termin)$ { + location ~ ^/((en|de)/)?(einschulungsuntersuchung/.+|(?:unternehmen/)?mein-bereich/.+|impfberatung/meine-termine(?:/.+)?|sexuelle-gesundheit/meine-termine)$ { include auth_request.conf; auth_request_set $resolved_location $upstream_http_location; diff --git a/reverse-proxy/employee-portal.conf b/reverse-proxy/employee-portal.conf index 492b816c78f31de5eade08438baf954b9babce11..befd7c74a7cb29f541130ce14d2934c287651f74 100644 --- a/reverse-proxy/employee-portal.conf +++ b/reverse-proxy/employee-portal.conf @@ -120,7 +120,34 @@ server { proxy_pass http://host.docker.internal:8099/; } + # Synapse SSO endpoints should not call auth service for Synapse JWT + location ~ ^(/api/synapse/_matrix/client/v3/login/sso/redirect|/api/synapse/_synapse/client/oidc/callback|/api/synapse/_matrix/client/v3/login)$ { + + rewrite ^/api/synapse/(.*) /$1 break; + + proxy_pass http://host.docker.internal:8008; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host:4000/api/synapse; # This value must match Synapse's public_baseurl in homeserver.template - otherwise synapse /login endpoint gets into "URL is not canonical" redirect death loop... + + # Synapse responses may be chunked, which is an HTTP/1.1 feature. + proxy_http_version 1.1; + + error_page # If synapse SSO redirect flow fails, we don't want to get stuck at broken synapse page, but redirect it back to employee-portal. + 400 401 402 403 404 405 406 408 409 410 411 412 413 414 415 416 421 429 + 500 501 502 503 504 505 507 + @synapse_error_handler; + } + + location @synapse_error_handler { + return 302 /?synapseError=UNEXPECTED_SERVER_ERROR; + } + location /api/synapse/ { + auth_request /synapse-auth; + auth_request_set $resolved_authorization $upstream_http_authorization; + proxy_set_header Authorization $resolved_authorization; + rewrite ^/api/synapse/(.*) /$1 break; # note: do not add a path (even a single /) after the port in `proxy_pass`, @@ -129,7 +156,7 @@ server { proxy_pass http://host.docker.internal:8008; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Host $host:4000/api/synapse; # Explicitly provide port to avoid "Requested URI %s is not canonical: redirecting to %s" death loop. + proxy_set_header Host $host:4000/api/synapse; # This value must match Synapse's public_baseurl in homeserver.template - otherwise synapse /login endpoint gets into "URL is not canonical" redirect death loop... # Nginx by default only allows file uploads up to 1M in size # Increase client_max_body_size to match max_upload_size defined in homeserver.yaml @@ -137,12 +164,6 @@ server { # Synapse responses may be chunked, which is an HTTP/1.1 feature. proxy_http_version 1.1; - - error_page 500 502 503 504 = @synapse_error_handler; - } - - location @synapse_error_handler { - return 302 /?synapseError=true; } # handle disabled backends as 404 @@ -206,6 +227,17 @@ server { error_page 302 = @rewrite_302_to_401; } + location = /synapse-auth { + internal; + + include forward_headers.conf; + + proxy_pass http://host.docker.internal:8092/synapse; + proxy_pass_request_body off; + + proxy_set_header Content-Length ""; + } + # Deny access to hidden files. location ~/\.{ deny all; diff --git a/reverse-proxy/forward_headers.conf b/reverse-proxy/forward_headers.conf index a3218e6f2732f0c318f22a8714678becb08ffe94..0428681c2cc134749178b9191f3bc114ab919a3e 100644 --- a/reverse-proxy/forward_headers.conf +++ b/reverse-proxy/forward_headers.conf @@ -3,5 +3,7 @@ proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-Matrix-Device-Id $http_x_forwarded_matrix_device_id; + # do not forward unverified header from client proxy_set_header Forwarded "";