diff --git a/iris-client-bff/pom.xml b/iris-client-bff/pom.xml index 805b0bf32..5625982e6 100644 --- a/iris-client-bff/pom.xml +++ b/iris-client-bff/pom.xml @@ -229,6 +229,11 @@ org.apache.httpcomponents httpclient + + org.apache.tika + tika-core + 2.2.0 + org.projectlombok lombok diff --git a/iris-client-bff/src/main/java/iris/client_bff/config/DataSubmissionConfig.java b/iris-client-bff/src/main/java/iris/client_bff/config/DataSubmissionConfig.java index 790825bbc..fba92be4f 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/config/DataSubmissionConfig.java +++ b/iris-client-bff/src/main/java/iris/client_bff/config/DataSubmissionConfig.java @@ -2,6 +2,7 @@ import iris.client_bff.cases.eps.CaseDataController; import iris.client_bff.events.eps.EventDataController; +import iris.client_bff.iris_messages.eps.IrisMessageDataController; import lombok.AllArgsConstructor; import org.springframework.context.annotation.Bean; @@ -21,6 +22,8 @@ public class DataSubmissionConfig { EventDataController eventDataController; + IrisMessageDataController irisMessageDataController; + @Bean(name = DATA_SUBMISSION_ENDPOINT) public CompositeJsonServiceExporter jsonRpcServiceImplExporter() { return createCompositeJsonServiceExporter(); @@ -34,7 +37,7 @@ public CompositeJsonServiceExporter jsonRpcServiceImplExporterWithSlash() { private CompositeJsonServiceExporter createCompositeJsonServiceExporter() { CompositeJsonServiceExporter compositeJsonServiceExporter = new CompositeJsonServiceExporter(); - compositeJsonServiceExporter.setServices(new Object[] { caseDataController, eventDataController }); + compositeJsonServiceExporter.setServices(new Object[] { caseDataController, eventDataController, irisMessageDataController }); compositeJsonServiceExporter.setAllowExtraParams(true); // Used to allow the EPS to add common parameters (e.g. a signature) and not have to change all methods. return compositeJsonServiceExporter; diff --git a/iris-client-bff/src/main/java/iris/client_bff/config/HibernateSearchConfig.java b/iris-client-bff/src/main/java/iris/client_bff/config/HibernateSearchConfig.java index 97241ca82..a571a9d03 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/config/HibernateSearchConfig.java +++ b/iris-client-bff/src/main/java/iris/client_bff/config/HibernateSearchConfig.java @@ -4,6 +4,8 @@ import iris.client_bff.cases.CaseDataRequest.DataRequestIdentifier; import iris.client_bff.events.EventDataRequest; import iris.client_bff.events.model.Location.LocationIdentifier; +import iris.client_bff.iris_messages.IrisMessage; +import iris.client_bff.iris_messages.IrisMessageFolder; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -24,8 +26,10 @@ import org.hibernate.search.mapper.orm.massindexing.MassIndexer; import org.hibernate.search.mapper.orm.session.SearchSession; import org.hibernate.search.mapper.pojo.bridge.IdentifierBridge; +import org.hibernate.search.mapper.pojo.bridge.ValueBridge; import org.hibernate.search.mapper.pojo.bridge.runtime.IdentifierBridgeFromDocumentIdentifierContext; import org.hibernate.search.mapper.pojo.bridge.runtime.IdentifierBridgeToDocumentIdentifierContext; +import org.hibernate.search.mapper.pojo.bridge.runtime.ValueBridgeToIndexedValueContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.stereotype.Component; @@ -138,6 +142,58 @@ public LocationIdentifier fromDocumentIdentifier(String value, return value == null ? null : LocationIdentifier.of(value); } }); + + context.bridges().exactType(IrisMessage.IrisMessageIdentifier.class) + .identifierBridge(new IdentifierBridge<>() { + + @Override + public String toDocumentIdentifier( + IrisMessage.IrisMessageIdentifier value, + IdentifierBridgeToDocumentIdentifierContext context + ) { + return value.toString(); + } + + @Override + public IrisMessage.IrisMessageIdentifier fromDocumentIdentifier( + String value, + IdentifierBridgeFromDocumentIdentifierContext context + ) { + return value == null ? null : IrisMessage.IrisMessageIdentifier.of(value); + } + }); + + context.bridges().exactType(IrisMessageFolder.IrisMessageFolderIdentifier.class) + .identifierBridge(new IdentifierBridge<>() { + + @Override + public String toDocumentIdentifier( + IrisMessageFolder.IrisMessageFolderIdentifier value, + IdentifierBridgeToDocumentIdentifierContext context + ) { + return value.toString(); + } + + @Override + public IrisMessageFolder.IrisMessageFolderIdentifier fromDocumentIdentifier( + String value, + IdentifierBridgeFromDocumentIdentifierContext context + ) { + return value == null ? null : IrisMessageFolder.IrisMessageFolderIdentifier.of(value); + } + }); + + context.bridges().exactType(IrisMessageFolder.IrisMessageFolderIdentifier.class) + .valueBridge(new ValueBridge() { + @Override + public String toIndexedValue( + IrisMessageFolder.IrisMessageFolderIdentifier value, + ValueBridgeToIndexedValueContext context + ) { + return value == null ? null : value.toString(); + } + }); + } } } diff --git a/iris-client-bff/src/main/java/iris/client_bff/core/utils/HibernateSearcher.java b/iris-client-bff/src/main/java/iris/client_bff/core/utils/HibernateSearcher.java index da8bc8d37..8e76ced87 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/core/utils/HibernateSearcher.java +++ b/iris-client-bff/src/main/java/iris/client_bff/core/utils/HibernateSearcher.java @@ -40,7 +40,7 @@ public SearchResult search(String search, Pageable pageable, String[] fie } private PredicateFinalStep createQuery(String keyword, String[] fields, - UnaryOperator> statusMatchFunc, SearchPredicateFactory f) { + UnaryOperator> searchInterceptor, SearchPredicateFactory f) { var boolPred = f.bool(); @@ -48,7 +48,7 @@ private PredicateFinalStep createQuery(String keyword, String[] fields, boolPred = boolPred.must(f2 -> createQueryPart(keywordPart, fields, f2)); } - return statusMatchFunc.apply(boolPred); + return searchInterceptor.apply(boolPred); } private PredicateFinalStep createQueryPart(String keyword, String[] fields, SearchPredicateFactory f) { diff --git a/iris-client-bff/src/main/java/iris/client_bff/core/web/error/GlobalControllerExceptionHandler.java b/iris-client-bff/src/main/java/iris/client_bff/core/web/error/GlobalControllerExceptionHandler.java index 07aab7421..9e6969bf6 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/core/web/error/GlobalControllerExceptionHandler.java +++ b/iris-client-bff/src/main/java/iris/client_bff/core/web/error/GlobalControllerExceptionHandler.java @@ -1,25 +1,33 @@ package iris.client_bff.core.web.error; +import static org.apache.commons.lang3.ArrayUtils.*; import static org.apache.commons.lang3.StringUtils.*; import iris.client_bff.core.web.filter.ApplicationRequestSizeLimitFilter.BlockLimitExceededException; import iris.client_bff.events.exceptions.IRISDataRequestException; import iris.client_bff.search_client.exceptions.IRISSearchException; import iris.client_bff.ui.messages.ErrorMessages; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.support.MessageSourceAccessor; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.http.HttpStatus; +import org.springframework.util.unit.DataSize; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; @Slf4j @ControllerAdvice +@RequiredArgsConstructor public class GlobalControllerExceptionHandler extends ResponseEntityExceptionHandler { + private final MessageSourceAccessor messages; + @ResponseStatus(value = HttpStatus.SERVICE_UNAVAILABLE, reason = ErrorMessages.LOCATION_SEARCH) @ExceptionHandler(IRISSearchException.class) public void handleIRISSearchException(IRISSearchException ex) { @@ -48,6 +56,16 @@ public void handleException(Exception ex) throws Exception { throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, getInternalMessage(ex)); } + @ExceptionHandler(MaxUploadSizeExceededException.class) + public void handleMaxUploadSizeExceededException(MaxUploadSizeExceededException ex) { + + var maxSize = DataSize.ofBytes(ex.getMaxUploadSize()); + + throw new ResponseStatusException( + HttpStatus.PAYLOAD_TOO_LARGE, + messages.getMessage("iris_message.max_upload_file_size", toArray(maxSize.toMegabytes() + " MB"))); + } + private String getInternalMessage(Exception ex) { var msg = defaultString(ex.getMessage()); diff --git a/iris-client-bff/src/main/java/iris/client_bff/hd_search/HdSearchException.java b/iris-client-bff/src/main/java/iris/client_bff/hd_search/HdSearchException.java new file mode 100644 index 000000000..0d205fe7f --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/hd_search/HdSearchException.java @@ -0,0 +1,27 @@ +package iris.client_bff.hd_search; + +import org.apache.commons.lang3.exception.ExceptionUtils; + +import java.io.Serial; + +public class HdSearchException extends RuntimeException { + + @Serial + private static final long serialVersionUID = -3649251457000674951L; + + public HdSearchException(String failedMethod, Throwable cause) { + super("Call to '" + failedMethod + "' failed", cause); + } + + public HdSearchException(String message) { + super(message); + } + + public HdSearchException(Throwable cause) { + super(cause); + } + + public String getErrorMessage() { + return ExceptionUtils.getRootCause(this).getMessage(); + } +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/hd_search/HealthDepartment.java b/iris-client-bff/src/main/java/iris/client_bff/hd_search/HealthDepartment.java new file mode 100644 index 000000000..de43504d5 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/hd_search/HealthDepartment.java @@ -0,0 +1,36 @@ +package iris.client_bff.hd_search; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class HealthDepartment { + + private String name; + private String rkiCode; + private String epsName; + private String department; + private Address address; + private ContactData contactData; + private ContactData covid19ContactData; + private ContactData entryContactData; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + private class Address { + private String street; + private String zipCode; + private String city; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + private class ContactData { + private String phone; + private String fax; + @JsonProperty("eMail") + private String eMail; + } +} \ No newline at end of file diff --git a/iris-client-bff/src/main/java/iris/client_bff/hd_search/eps/EPSHdSearchClient.java b/iris-client-bff/src/main/java/iris/client_bff/hd_search/eps/EPSHdSearchClient.java new file mode 100644 index 000000000..7b7d83a42 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/hd_search/eps/EPSHdSearchClient.java @@ -0,0 +1,34 @@ +package iris.client_bff.hd_search.eps; + +import com.googlecode.jsonrpc4j.JsonRpcHttpClient; +import iris.client_bff.config.BackendServiceProperties; +import iris.client_bff.hd_search.HdSearchException; +import iris.client_bff.hd_search.HealthDepartment; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@AllArgsConstructor +public class EPSHdSearchClient { + + private final JsonRpcHttpClient epsRpcClient; + private BackendServiceProperties config; + + public List searchForHd(String search) { + + var methodName = config.getEndpoint() + ".searchForHd"; + Map payload = Map.of("searchKeyword", search); + + try { + return Arrays.stream(epsRpcClient.invoke(methodName, payload, HealthDepartment[].class)).toList(); + } catch (Throwable t) { + throw new HdSearchException(methodName, t); + } + } +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessage.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessage.java new file mode 100644 index 000000000..04d0a90ea --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessage.java @@ -0,0 +1,94 @@ +package iris.client_bff.iris_messages; + +import iris.client_bff.core.Aggregate; +import iris.client_bff.core.Id; +import lombok.*; +import org.hibernate.search.engine.backend.types.Sortable; +import org.hibernate.search.mapper.pojo.mapping.definition.annotation.*; + +import javax.persistence.*; +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "iris_message") +@Indexed +@Data +@EqualsAndHashCode(callSuper = true, exclude = "folder") +@NoArgsConstructor +public class IrisMessage extends Aggregate { + + public static final int SUBJECT_MAX_LENGTH = 500; + public static final int BODY_MAX_LENGTH = 6000; + + { + id = IrisMessage.IrisMessageIdentifier.of(UUID.randomUUID()); + } + + @ManyToOne + @JoinColumn(name="folder_id", nullable=false) + @IndexedEmbedded(includeEmbeddedObjectId = true) + private IrisMessageFolder folder; + + @Column(nullable = false) + @KeywordField(sortable = Sortable.YES, normalizer = "german") + private String subject; + + @Column(nullable = false) + private String body; + + @Column(nullable = false) + @Embedded + @IndexedEmbedded + @AttributeOverrides({ + @AttributeOverride( name = "id", column = @Column(name = "hd_author_id")), + @AttributeOverride( name = "name", column = @Column(name = "hd_author_name")) + }) + private IrisMessageHdContact hdAuthor; + + @Column(nullable = false) + @Embedded + @IndexedEmbedded + @AttributeOverrides({ + @AttributeOverride( name = "id", column = @Column(name = "hd_recipient_id")), + @AttributeOverride( name = "name", column = @Column(name = "hd_recipient_name")) + }) + private IrisMessageHdContact hdRecipient; + + private Boolean isRead; + + @OneToMany(mappedBy = "message", cascade = CascadeType.ALL, orphanRemoval = true) + private List fileAttachments = new ArrayList<>(); + + @Embeddable + @EqualsAndHashCode + @RequiredArgsConstructor(staticName = "of") + @NoArgsConstructor(force = true, access = AccessLevel.PRIVATE) + public static class IrisMessageIdentifier implements Id, Serializable { + + @Serial + private static final long serialVersionUID = 1140444389070674189L; + + private final UUID id; + + /** + * for JSON deserialization + */ + public static IrisMessage.IrisMessageIdentifier of(String uuid) { + return of(UUID.fromString(uuid)); + } + + @Override + public String toString() { + return id.toString(); + } + + public UUID toUUID() { + return id; + } + } + +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageBuilder.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageBuilder.java new file mode 100644 index 000000000..3d0775c4e --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageBuilder.java @@ -0,0 +1,113 @@ +package iris.client_bff.iris_messages; + +import iris.client_bff.iris_messages.eps.EPSIrisMessageClient; +import iris.client_bff.iris_messages.eps.IrisMessageTransferDto; +import iris.client_bff.iris_messages.web.IrisMessageInsertDto; +import iris.client_bff.iris_messages.web.IrisMessageInsertFileDto; +import lombok.RequiredArgsConstructor; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class IrisMessageBuilder { + + private final IrisMessageFolderRepository folderRepository; + private final EPSIrisMessageClient irisMessageClient; + private final MessageSourceAccessor messages; + + public IrisMessage build(IrisMessageTransferDto messageTransfer) throws IrisMessageException { + + Optional folder = this.folderRepository + .findFirstByContextAndParentFolderIsNull(IrisMessageContext.INBOX); + if (folder.isEmpty()) { + throw new IrisMessageException(messages.getMessage("iris_message.invalid_folder")); + } + + IrisMessageHdContact hdAuthor = new IrisMessageHdContact( + messageTransfer.getHdAuthor().getId(), + messageTransfer.getHdAuthor().getName()); + + IrisMessageHdContact hdRecipient = new IrisMessageHdContact( + messageTransfer.getHdRecipient().getId(), + messageTransfer.getHdRecipient().getName()); + + // ensure that the message was sent to the correct recipient + IrisMessageHdContact hdOwn = this.irisMessageClient.getOwnIrisMessageHdContact(); + if (!Objects.equals(hdOwn.getId(), hdRecipient.getId())) { + throw new IrisMessageException(messages.getMessage("iris_message.invalid_recipient")); + } + + IrisMessage message = new IrisMessage(); + + List files = new ArrayList<>(); + if (messageTransfer.getFileAttachments() != null) { + for ( IrisMessageTransferDto.FileAttachment file : messageTransfer.getFileAttachments() ) { + IrisMessageFile messageFile = new IrisMessageFile() + .setMessage(message) + .setContent(Base64.getDecoder().decode(file.getContent())) + .setName(file.getName()); + files.add(messageFile); + } + } + + message + .setHdAuthor(hdAuthor) + .setHdRecipient(hdRecipient) + .setSubject(messageTransfer.getSubject()) + .setBody(messageTransfer.getBody()) + .setFolder(folder.get()) + .setIsRead(false) + .setFileAttachments(files); + + return message; + } + + public IrisMessage build(IrisMessageInsertDto messageInsert) throws IrisMessageException { + + Optional folder = this.folderRepository + .findFirstByContextAndParentFolderIsNull(IrisMessageContext.OUTBOX); + if (folder.isEmpty()) { + throw new IrisMessageException("iris_message.invalid_folder"); + } + + IrisMessageHdContact hdAuthor = this.irisMessageClient.getOwnIrisMessageHdContact(); + + Optional hdRecipient = this.irisMessageClient + .findIrisMessageHdContactById(messageInsert.getHdRecipient()); + if (hdRecipient.isEmpty()) { + throw new IrisMessageException("iris_message.invalid_recipient"); + } + + IrisMessage message = new IrisMessage(); + + List files = new ArrayList<>(); + if (messageInsert.getFileAttachments() != null) { + for ( IrisMessageInsertFileDto file : messageInsert.getFileAttachments() ) { + IrisMessageFile messageFile = new IrisMessageFile() + .setMessage(message) + .setContent(Base64.getDecoder().decode(file.getContent())) + .setName(file.getName()); + files.add(messageFile); + } + } + + message + .setHdAuthor(hdAuthor) + .setHdRecipient(hdRecipient.get()) + .setSubject(messageInsert.getSubject()) + .setBody(messageInsert.getBody()) + .setFolder(folder.get()) + .setIsRead(true) + .setFileAttachments(files); + + return message; + } +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageContext.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageContext.java new file mode 100644 index 000000000..a70d5e4ca --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageContext.java @@ -0,0 +1,6 @@ +package iris.client_bff.iris_messages; + +public enum IrisMessageContext { + INBOX, + OUTBOX; +} \ No newline at end of file diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageException.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageException.java new file mode 100644 index 000000000..9a1f2b7e3 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageException.java @@ -0,0 +1,26 @@ +package iris.client_bff.iris_messages; + +import org.apache.commons.lang3.exception.ExceptionUtils; + +import java.io.Serial; + +public class IrisMessageException extends RuntimeException { + @Serial + private static final long serialVersionUID = 8068203942662884747L; + + public IrisMessageException(String failedMethod, Throwable cause) { + super("Call to '" + failedMethod + "' failed", cause); + } + + public IrisMessageException(String message) { + super(message); + } + + public IrisMessageException(Throwable cause) { + super(cause); + } + + public String getErrorMessage() { + return ExceptionUtils.getRootCause(this).getMessage(); + } +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageFile.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageFile.java new file mode 100644 index 000000000..497e1576b --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageFile.java @@ -0,0 +1,63 @@ +package iris.client_bff.iris_messages; + +import iris.client_bff.core.Aggregate; +import iris.client_bff.core.Id; +import lombok.*; + +import javax.persistence.*; +import java.io.Serial; +import java.io.Serializable; +import java.util.UUID; + +@Entity +@Table(name = "iris_message_file") +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +public class IrisMessageFile extends Aggregate { + + public static final int NAME_MAX_LENGTH = 255; + + { + id = IrisMessageFileIdentifier.of(UUID.randomUUID()); + } + + @Column(nullable = false) + private String name; + + @Column(length = 16777215) + private byte[] content; + + @ToString.Exclude + @ManyToOne + @JoinColumn(name="message_id") + private IrisMessage message; + + @Embeddable + @EqualsAndHashCode + @RequiredArgsConstructor(staticName = "of") + @NoArgsConstructor(force = true, access = AccessLevel.PROTECTED) + public static class IrisMessageFileIdentifier implements Id, Serializable { + + @Serial + private static final long serialVersionUID = -7602440129090196288L; + + private final UUID id; + + /** + * for JSON deserialization + */ + public static IrisMessageFile.IrisMessageFileIdentifier of(String uuid) { + return of(UUID.fromString(uuid)); + } + + @Override + public String toString() { + return id.toString(); + } + + public UUID toUUID() { + return id; + } + } +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageFileRepository.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageFileRepository.java new file mode 100644 index 000000000..729f42414 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageFileRepository.java @@ -0,0 +1,8 @@ +package iris.client_bff.iris_messages; + +import iris.client_bff.iris_messages.IrisMessageFile.IrisMessageFileIdentifier; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface IrisMessageFileRepository + extends JpaRepository {} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageFolder.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageFolder.java new file mode 100644 index 000000000..83277dbfc --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageFolder.java @@ -0,0 +1,81 @@ +package iris.client_bff.iris_messages; + +import iris.client_bff.core.Aggregate; +import iris.client_bff.core.Id; +import lombok.AccessLevel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Set; +import java.util.UUID; + +import javax.persistence.*; + +@Entity +@Table(name = "iris_message_folder") +@SecondaryTable(name = "iris_message_folder_default") +@Data +@EqualsAndHashCode(callSuper = true, exclude = "messages") +@NoArgsConstructor +public class IrisMessageFolder extends Aggregate { + + { + id = IrisMessageFolder.IrisMessageFolderIdentifier.of(UUID.randomUUID()); + } + + @ToString.Exclude + @OneToMany(mappedBy = "folder", cascade = CascadeType.ALL, orphanRemoval = true) + private Set messages; + + @Column(nullable = false) + private String name; + + @Enumerated(EnumType.STRING) + private IrisMessageContext context; + + @Embedded + @AttributeOverride(name = "id", + column = @Column(name = "id", table = "iris_message_folder_default", insertable = false, updatable = false)) + private IrisMessageFolderIdentifier defaultFolder; + + @Embedded + @AttributeOverride(name = "id", column = @Column(name = "parent_folder")) + private IrisMessageFolderIdentifier parentFolder; + + public Boolean getIsDefault() { + return this.getId().equals(this.defaultFolder); + } + + @Embeddable + @EqualsAndHashCode + @RequiredArgsConstructor(staticName = "of") + @NoArgsConstructor(force = true, access = AccessLevel.PRIVATE) + public static class IrisMessageFolderIdentifier implements Id, Serializable { + + @Serial + private static final long serialVersionUID = -8255216015747810442L; + + final UUID id; + + /** + * for JSON deserialization + */ + public static IrisMessageFolder.IrisMessageFolderIdentifier of(String uuid) { + return of(UUID.fromString(uuid)); + } + + @Override + public String toString() { + return id.toString(); + } + + public UUID toUUID() { + return id; + } + } +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageFolderInitializer.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageFolderInitializer.java new file mode 100644 index 000000000..bb27a719a --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageFolderInitializer.java @@ -0,0 +1,36 @@ +package iris.client_bff.iris_messages; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.PostConstruct; + +import org.springframework.stereotype.Component; + +@Component +@AllArgsConstructor +@Slf4j +public class IrisMessageFolderInitializer { + + private IrisMessageFolderRepository folderRepository; + + @PostConstruct + protected void createMessageFoldersIfNotExist() { + if (folderRepository.count() == 0) { + IrisMessageFolder inboxFolder = new IrisMessageFolder(); + inboxFolder + .setName("Posteingang") + .setDefaultFolder(inboxFolder.getId()) + .setContext(IrisMessageContext.INBOX); + IrisMessageFolder outboxFolder = new IrisMessageFolder(); + outboxFolder + .setName("Postausgang") + .setDefaultFolder(inboxFolder.getId()) + .setContext(IrisMessageContext.OUTBOX); + folderRepository.save(inboxFolder); + folderRepository.save(outboxFolder); + } else { + log.info("Initial iris message folders already exists. Skip creating of folders."); + } + } +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageFolderRepository.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageFolderRepository.java new file mode 100644 index 000000000..f86134cf7 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageFolderRepository.java @@ -0,0 +1,16 @@ +package iris.client_bff.iris_messages; + +import org.springframework.data.jpa.repository.JpaRepository; + +import iris.client_bff.iris_messages.IrisMessageFolder.IrisMessageFolderIdentifier; + +import java.util.List; +import java.util.Optional; + +public interface IrisMessageFolderRepository extends JpaRepository { + + Optional findFirstByContextAndParentFolderIsNull(IrisMessageContext context); + + List findAllByParentFolder(IrisMessageFolderIdentifier parentFolder); + +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageHdContact.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageHdContact.java new file mode 100644 index 000000000..9ef310a7f --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageHdContact.java @@ -0,0 +1,36 @@ +package iris.client_bff.iris_messages; + +import lombok.*; +import org.hibernate.search.engine.backend.types.Sortable; +import org.hibernate.search.mapper.pojo.mapping.definition.annotation.KeywordField; + +import javax.persistence.Embeddable; +import javax.persistence.Transient; + +@Data +@Embeddable +@Builder +@NoArgsConstructor +public class IrisMessageHdContact { + + public static final int ID_MAX_LENGTH = 255; + public static final int NAME_MAX_LENGTH = 255; + + private String id; + @KeywordField(sortable = Sortable.YES, normalizer = "german") + private String name; + + @ToString.Exclude + @Transient + private Boolean isOwn; + + public IrisMessageHdContact(String id, String name) { + this.id = id; + this.name = name; + } + + public IrisMessageHdContact(String id, String name, Boolean isOwn) { + this(id, name); + this.isOwn = isOwn; + } +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageRepository.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageRepository.java new file mode 100644 index 000000000..c7b65e591 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageRepository.java @@ -0,0 +1,17 @@ +package iris.client_bff.iris_messages; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface IrisMessageRepository extends JpaRepository { + + @Query("select count(m) from IrisMessage m where (m.isRead is null or m.isRead = false) and m.folder.id = :folderId") + int getCountUnreadByFolderId(IrisMessageFolder.IrisMessageFolderIdentifier folderId); + + int countByIsReadFalseOrIsReadIsNull(); + + Page findAllByFolderIdOrderByIsReadAsc(IrisMessageFolder.IrisMessageFolderIdentifier folder, Pageable pageable); + +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageService.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageService.java new file mode 100644 index 000000000..98fbd97a1 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessageService.java @@ -0,0 +1,99 @@ +package iris.client_bff.iris_messages; + +import iris.client_bff.core.utils.HibernateSearcher; +import iris.client_bff.hd_search.HealthDepartment; +import iris.client_bff.hd_search.eps.EPSHdSearchClient; +import iris.client_bff.iris_messages.IrisMessage.IrisMessageIdentifier; +import iris.client_bff.iris_messages.IrisMessageFolder.IrisMessageFolderIdentifier; +import iris.client_bff.iris_messages.eps.EPSIrisMessageClient; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class IrisMessageService { + + private static final String[] SEARCH_FIELDS = { "subject", "hdAuthor.name", "hdRecipient.name" }; + + private final IrisMessageRepository messageRepository; + private final IrisMessageFolderRepository folderRepository; + private final IrisMessageFileRepository fileRepository; + private final HibernateSearcher searcher; + private final EPSIrisMessageClient irisMessageClient; + private final EPSHdSearchClient hdSearchClient; + + public Optional findById(IrisMessageIdentifier messageId) { + return messageRepository.findById(messageId); + } + + public Page search(IrisMessageFolderIdentifier folderId, String searchString, Pageable pageable) { + if (StringUtils.isEmpty(searchString)) { + return messageRepository.findAllByFolderIdOrderByIsReadAsc(folderId, pageable); + } + var result = searcher.search( + searchString, + pageable, + SEARCH_FIELDS, + it -> it.must(f2 -> f2.match().field("folder.id").matching(folderId)), + IrisMessage.class); + return new PageImpl<>(result.hits(), pageable, result.total().hitCount()); + } + + public int getCountUnreadByFolderId(IrisMessageFolderIdentifier folderId) { + return messageRepository.getCountUnreadByFolderId(folderId); + } + + public int getCountUnread() { + return messageRepository.countByIsReadFalseOrIsReadIsNull(); + } + + public List getFolders() { + return folderRepository.findAll(); + } + + public Optional findFileById(IrisMessageFile.IrisMessageFileIdentifier fileId) { + return this.fileRepository.findById(fileId); + } + + public List getHdContacts(String search) throws IrisMessageException { + + List contacts = this.irisMessageClient.getIrisMessageHdContacts(); + + if (search == null || search.equals("")) { + return contacts; + } + + List healthDepartments = this.hdSearchClient.searchForHd(search); + List hdEpsNames = healthDepartments + .stream() + .map(HealthDepartment::getEpsName) + .filter(Objects::nonNull) + .toList(); + + return contacts + .stream() + .filter(contact -> hdEpsNames.contains(contact.getId()) || contact.getName().contains(search) || contact.getId().contains(search)) + .toList(); + } + + public IrisMessageHdContact getOwnHdContact() { + return this.irisMessageClient.getOwnIrisMessageHdContact(); + } + + public IrisMessage sendMessage(IrisMessage message) throws IrisMessageException { + this.irisMessageClient.createIrisMessage(message); + return this.messageRepository.save(message); + } + + public IrisMessage saveMessage(IrisMessage message) { + return this.messageRepository.save(message); + } + +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/eps/EPSIrisMessageClient.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/eps/EPSIrisMessageClient.java new file mode 100644 index 000000000..2b44541b6 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/eps/EPSIrisMessageClient.java @@ -0,0 +1,73 @@ +package iris.client_bff.iris_messages.eps; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.googlecode.jsonrpc4j.JsonRpcHttpClient; +import iris.client_bff.config.RPCClientProperties; +import iris.client_bff.iris_messages.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import javax.validation.Valid; +import javax.validation.constraints.NotNull; +import java.util.*; + +@Service +@RequiredArgsConstructor +public class EPSIrisMessageClient { + + private static final int READ_TIMEOUT = 12 * 1000; + + private final JsonRpcHttpClient epsRpcClient; + private final RPCClientProperties rpcClientProps; + + public IrisMessageHdContact getOwnIrisMessageHdContact() { + String ownId = rpcClientProps.getOwnEndpoint(); + return new IrisMessageHdContact(ownId, ownId, true); + } + + public Optional findIrisMessageHdContactById(String contactId) throws IrisMessageException { + List contacts = this.getIrisMessageHdContacts(); + return contacts.stream().filter(contact -> contact.getId().equals(contactId)).findFirst(); + } + + public List getIrisMessageHdContacts() throws IrisMessageException { + var methodName = rpcClientProps.getOwnEndpoint() + "._directory"; + try { + return epsRpcClient.invoke(methodName, null, Directory.class).entries().stream() + .filter(directoryEntry -> + directoryEntry.groups() != null && + directoryEntry.groups().contains("health-departments") && + directoryEntry.services() != null && + directoryEntry.services().stream().anyMatch(service -> service.name().equals("inter-ga-communication"))) + .map(directoryEntry -> new IrisMessageHdContact(directoryEntry.name, directoryEntry.name)) + .sorted(Comparator.comparing(IrisMessageHdContact::getName, String.CASE_INSENSITIVE_ORDER)) + .toList(); + } catch (Throwable t) { + throw new IrisMessageException(t); + } + } + + public void createIrisMessage(IrisMessage message) throws IrisMessageException { + String methodName = message.getHdRecipient().getId() + ".createIrisMessage"; + Map payload = Map.of("irisMessage", IrisMessageTransferDto.fromEntity(message)); + int defaultReadTimeout = this.epsRpcClient.getReadTimeoutMillis(); + try { + this.epsRpcClient.setReadTimeoutMillis(READ_TIMEOUT); + this.epsRpcClient.invoke(methodName, payload); + } catch (Throwable t) { + throw new IrisMessageException(t); + } finally { + this.epsRpcClient.setReadTimeoutMillis(defaultReadTimeout); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record Directory(@NotNull List<@Valid DirectoryEntry> entries) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + record DirectoryEntry(@NotNull String name, Set groups, List<@Valid DirectoryEntryService> services) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + record DirectoryEntryService(@NotNull String name) {} + +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/eps/IrisMessageDataController.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/eps/IrisMessageDataController.java new file mode 100644 index 000000000..33da87411 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/eps/IrisMessageDataController.java @@ -0,0 +1,12 @@ +package iris.client_bff.iris_messages.eps; + +import com.googlecode.jsonrpc4j.JsonRpcParam; +import iris.client_bff.iris_messages.IrisMessageException; + +import javax.validation.Valid; + +public interface IrisMessageDataController { + IrisMessageTransferDto createIrisMessage( + @Valid @JsonRpcParam(value = "irisMessage") IrisMessageTransferDto messageTransfer + ) throws IrisMessageException; +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/eps/IrisMessageDataControllerImpl.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/eps/IrisMessageDataControllerImpl.java new file mode 100644 index 000000000..fe016950b --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/eps/IrisMessageDataControllerImpl.java @@ -0,0 +1,38 @@ +package iris.client_bff.iris_messages.eps; + +import iris.client_bff.config.JsonRpcDataValidator; +import iris.client_bff.iris_messages.IrisMessage; +import iris.client_bff.iris_messages.IrisMessageBuilder; +import iris.client_bff.iris_messages.IrisMessageException; +import iris.client_bff.iris_messages.IrisMessageService; +import lombok.RequiredArgsConstructor; + +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class IrisMessageDataControllerImpl implements IrisMessageDataController { + + private final IrisMessageTransferDefuse messageTransferDefuse; + private final IrisMessageService irisMessageService; + private final IrisMessageBuilder irisMessageBuilder; + private final MessageSourceAccessor messages; + + private final JsonRpcDataValidator jsonRpcDataValidator; + + @Override + public IrisMessageTransferDto createIrisMessage(IrisMessageTransferDto messageTransfer) throws IrisMessageException { + if (messageTransfer == null) { + throw new IrisMessageException(messages.getMessage("iris_message.invalid_id")); + } + try { + jsonRpcDataValidator.validateData(messageTransfer); + IrisMessage message = this.irisMessageBuilder.build(this.messageTransferDefuse.defuse(messageTransfer)); + IrisMessage savedMessage = this.irisMessageService.saveMessage(message); + return IrisMessageTransferDto.fromEntity(savedMessage); + } catch (Throwable e) { + throw new IrisMessageException(e); + } + } +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/eps/IrisMessageTransferDefuse.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/eps/IrisMessageTransferDefuse.java new file mode 100644 index 000000000..7eb6fa186 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/eps/IrisMessageTransferDefuse.java @@ -0,0 +1,56 @@ +package iris.client_bff.iris_messages.eps; + +import iris.client_bff.core.utils.ValidationHelper; +import iris.client_bff.iris_messages.IrisMessage; +import iris.client_bff.iris_messages.IrisMessageFile; +import iris.client_bff.iris_messages.IrisMessageHdContact; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +import static iris.client_bff.ui.messages.ErrorMessages.INVALID_INPUT_STRING; + +@Service +@RequiredArgsConstructor +public class IrisMessageTransferDefuse { + + private final ValidationHelper validationHelper; + + IrisMessageTransferDto defuse(IrisMessageTransferDto message) { + return IrisMessageTransferDto.builder() + .hdAuthor(this.defuse(message.getHdAuthor(), "author")) + .hdRecipient(this.defuse(message.getHdRecipient(), "recipient")) + .subject(this.defuse(message.getSubject(), "subject", IrisMessage.SUBJECT_MAX_LENGTH)) + .body(this.defuse(message.getBody(), "body", IrisMessage.BODY_MAX_LENGTH)) + .fileAttachments(this.defuse(message.getFileAttachments())) + .build(); + } + + private IrisMessageTransferDto.HdContact defuse(IrisMessageTransferDto.HdContact contact, String field) { + return new IrisMessageTransferDto.HdContact() + .setId(this.defuse(contact.getId(), field + ".id", IrisMessageHdContact.ID_MAX_LENGTH)) + .setName(this.defuse(contact.getName(), field + ".id", IrisMessageHdContact.NAME_MAX_LENGTH)); + } + + private List defuse(List fileAttachments) { + return fileAttachments.stream().map(this::defuse).collect(Collectors.toList()); + } + + private IrisMessageTransferDto.FileAttachment defuse(IrisMessageTransferDto.FileAttachment fileAttachment) { + return new IrisMessageTransferDto.FileAttachment() + .setName(this.defuse(fileAttachment.getName(), "fileAttachment.name", IrisMessageFile.NAME_MAX_LENGTH)) + .setContent(fileAttachment.getContent()); + } + + private String defuse(String input, String field, int maxLength) { + if (input == null) return null; + if (this.validationHelper.isPossibleAttack(input, field, true)) { + return INVALID_INPUT_STRING; + } + return StringUtils.truncate(input, maxLength); + } + +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/eps/IrisMessageTransferDto.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/eps/IrisMessageTransferDto.java new file mode 100644 index 000000000..dd5b3a63a --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/eps/IrisMessageTransferDto.java @@ -0,0 +1,88 @@ +package iris.client_bff.iris_messages.eps; + +import iris.client_bff.iris_messages.IrisMessage; +import iris.client_bff.iris_messages.IrisMessageFile; +import iris.client_bff.iris_messages.IrisMessageHdContact; +import lombok.*; + +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; +import java.util.Base64; +import java.util.List; +import java.util.stream.Collectors; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IrisMessageTransferDto { + + // we send the name & id as we do not know if the author`s health department is accessible by the recipient + @Valid + private HdContact hdAuthor; + + // the recipient is used to check if it is identical with the endpoint identifier of the health department, the message is sent to + @Valid + private HdContact hdRecipient; + + @NotBlank + @Size(max = IrisMessage.SUBJECT_MAX_LENGTH) + private String subject; + + @NotBlank + @Size(max = IrisMessage.BODY_MAX_LENGTH) + private String body; + + @Valid + private List fileAttachments; + + public static IrisMessageTransferDto fromEntity(IrisMessage message) { + return IrisMessageTransferDto.builder() + .hdAuthor(HdContact.fromEntity(message.getHdAuthor())) + .hdRecipient(HdContact.fromEntity(message.getHdRecipient())) + .subject(message.getSubject()) + .body(message.getBody()) + .fileAttachments(FileAttachment.fromEntity(message.getFileAttachments())) + .build(); + } + + @Data + public static class HdContact { + + @NotBlank + @Size(max = IrisMessageHdContact.ID_MAX_LENGTH) + private String id; + + @NotBlank + @Size(max = IrisMessageHdContact.NAME_MAX_LENGTH) + private String name; + + public static HdContact fromEntity(IrisMessageHdContact contact) { + return new HdContact() + .setId(contact.getId()) + .setName(contact.getName()); + } + } + + @Data + public static class FileAttachment { + + @NotBlank + @Size(max = IrisMessageFile.NAME_MAX_LENGTH) + private String name; + + private String content; + + public static List fromEntity(List files) { + return files.stream().map(FileAttachment::fromEntity).collect(Collectors.toList()); + } + + public static FileAttachment fromEntity(IrisMessageFile file) { + return new FileAttachment() + .setName(file.getName()) + .setContent(Base64.getEncoder().encodeToString(file.getContent())); + } + } + +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/validation/FileTypeConstraint.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/validation/FileTypeConstraint.java new file mode 100644 index 000000000..2734e6edb --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/validation/FileTypeConstraint.java @@ -0,0 +1,22 @@ +package iris.client_bff.iris_messages.validation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import javax.validation.Constraint; +import javax.validation.Payload; + +@Documented +@Constraint(validatedBy = FileTypeValidator.class) +@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.TYPE_USE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface FileTypeConstraint { + String message() default "{iris_message.invalid_file_type}"; + + Class>[] groups() default {}; + + Class extends Payload>[] payload() default {}; +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/validation/FileTypeValidator.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/validation/FileTypeValidator.java new file mode 100644 index 000000000..f03904c14 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/validation/FileTypeValidator.java @@ -0,0 +1,42 @@ +package iris.client_bff.iris_messages.validation; + +import iris.client_bff.iris_messages.web.IrisMessageInsertFileDto; +import org.apache.tika.Tika; +import org.springframework.http.MediaType; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.Base64; +import java.util.Objects; + +public class FileTypeValidator implements ConstraintValidator { + + //@todo: move ALLOWED_TYPES to a better place + public static final String[] ALLOWED_TYPES = {"image/*"}; + + @Override + public void initialize(FileTypeConstraint constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(IrisMessageInsertFileDto file, ConstraintValidatorContext ctx) { + if (file == null) return true; + try { + Tika tika = new Tika(); + String type = tika.detect(Base64.getDecoder().decode(file.getContent())); + MediaType mediaType = MediaType.valueOf(type); + for (String allowedType : ALLOWED_TYPES) { + String[] typeMap = allowedType.split("/"); + if (typeMap.length != 2) return false; + if (mediaType.getType().equals(typeMap[0])) { + if (Objects.equals(typeMap[1], "*")) return true; + if (mediaType.getSubtype().equals(typeMap[1])) return true; + } + } + } catch (Throwable e) { + return false; + } + return false; + } +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/validation/IrisMessageFileConstraint.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/validation/IrisMessageFileConstraint.java new file mode 100644 index 000000000..83ee6106f --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/validation/IrisMessageFileConstraint.java @@ -0,0 +1,22 @@ +package iris.client_bff.iris_messages.validation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import javax.validation.Constraint; +import javax.validation.Payload; + +@Documented +@Constraint(validatedBy = IrisMessageFileValidator.class) +@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.TYPE_USE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface IrisMessageFileConstraint { + String message() default "{iris_message.invalid_file}"; + + Class>[] groups() default {}; + + Class extends Payload>[] payload() default {}; +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/validation/IrisMessageFileValidator.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/validation/IrisMessageFileValidator.java new file mode 100644 index 000000000..d7ea7ee2a --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/validation/IrisMessageFileValidator.java @@ -0,0 +1,36 @@ +package iris.client_bff.iris_messages.validation; + +import iris.client_bff.core.utils.ValidationHelper; +import iris.client_bff.iris_messages.web.IrisMessageInsertFileDto; +import lombok.RequiredArgsConstructor; +import org.apache.tika.Tika; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.Base64; + +@Component +@RequiredArgsConstructor +public class IrisMessageFileValidator implements ConstraintValidator { + + private final ValidationHelper validationHelper; + + @Override + public boolean isValid(IrisMessageInsertFileDto file, ConstraintValidatorContext ctx) { + if (file == null) return true; + try { + Tika tika = new Tika(); + String type = tika.detect(Base64.getDecoder().decode(file.getContent())); + MediaType.valueOf(type); + } catch (Throwable e) { + return false; + } + if (validationHelper.isPossibleAttack(file.getName(), "fileName", false)) { + return false; + } + return file.getName() != null; + } + +} \ No newline at end of file diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageController.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageController.java new file mode 100644 index 000000000..05d85423a --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageController.java @@ -0,0 +1,218 @@ +package iris.client_bff.iris_messages.web; + +import iris.client_bff.core.utils.ValidationHelper; +import iris.client_bff.iris_messages.IrisMessage; +import iris.client_bff.iris_messages.IrisMessage.IrisMessageIdentifier; +import iris.client_bff.iris_messages.IrisMessageBuilder; +import iris.client_bff.iris_messages.IrisMessageException; +import iris.client_bff.iris_messages.IrisMessageFile; +import iris.client_bff.iris_messages.IrisMessageFolder; +import iris.client_bff.iris_messages.IrisMessageFolder.IrisMessageFolderIdentifier; +import iris.client_bff.iris_messages.IrisMessageHdContact; +import iris.client_bff.iris_messages.IrisMessageService; +import iris.client_bff.iris_messages.validation.FileTypeValidator; +import iris.client_bff.ui.messages.ErrorMessages; +import lombok.AllArgsConstructor; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.validation.Valid; +import javax.validation.constraints.Size; + +import org.apache.tika.Tika; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindingResult; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +@RestController +@AllArgsConstructor +@Validated +@RequestMapping("/iris-messages") +public class IrisMessageController { + + private static final String FIELD_SEARCH = "search"; + + private static final String FIELD_HD_RECIPIENT = "hdRecipient"; + private static final String FIELD_SUBJECT = "subject"; + private static final String FIELD_BODY = "body"; + private static final String FIELD_FILE_ATTACHMENT = "fileAttachment"; + + private IrisMessageService irisMessageService; + private final IrisMessageBuilder irisMessageBuilder; + private final ValidationHelper validationHelper; + + @GetMapping() + public Page getMessages( + @RequestParam() IrisMessageFolderIdentifier folder, + @RequestParam(required = false) String search, + Pageable pageable) { + this.validateField(search, FIELD_SEARCH); + return this.irisMessageService.search(folder, search, pageable).map(IrisMessageListItemDto::fromEntity); + } + + @PostMapping() + @ResponseStatus(HttpStatus.CREATED) + public ResponseEntity createAndSendMessage(@Valid @RequestBody IrisMessageInsertDto irisMessageInsert, + BindingResult bindingResult) { + this.validateConstraints(bindingResult); + this.validateIrisMessageInsert(irisMessageInsert); + try { + IrisMessage message = irisMessageBuilder.build(irisMessageInsert); + IrisMessage sentMessage = irisMessageService.sendMessage(message); + URI location = ServletUriComponentsBuilder + .fromCurrentRequest() + .path("/{id}") + .buildAndExpand(sentMessage.getId()) + .toUri(); + return ResponseEntity.created(location).build(); + } catch (Throwable e) { + String errorMessage = e instanceof IrisMessageException ime + ? ime.getErrorMessage() + : "iris_message.submission_error"; + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, errorMessage); + } + } + + private void validateIrisMessageInsert(IrisMessageInsertDto irisMessageInsert) { + if (irisMessageInsert == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "iris_message.submission_error"); + } + this.validateField(irisMessageInsert.getHdRecipient(), FIELD_HD_RECIPIENT); + this.validateField(irisMessageInsert.getSubject(), FIELD_SUBJECT); + this.validateField(irisMessageInsert.getBody(), FIELD_BODY); + + if (irisMessageInsert.getFileAttachments() != null) { + for ( IrisMessageInsertFileDto file : irisMessageInsert.getFileAttachments() ) { + this.validateField(file.getName(), FIELD_FILE_ATTACHMENT); + } + } + } + + @GetMapping("/allowed-file-types") + public ResponseEntity getAllowedFileTypes() { + return ResponseEntity.ok(FileTypeValidator.ALLOWED_TYPES); + } + + @GetMapping("/{messageId}") + public ResponseEntity getMessageDetails(@PathVariable IrisMessageIdentifier messageId) { + Optional irisMessage = this.irisMessageService.findById(messageId); + if (irisMessage.isPresent()) { + IrisMessageDetailsDto messageDetailsDto = IrisMessageDetailsDto.fromEntity(irisMessage.get()); + return ResponseEntity.ok(messageDetailsDto); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } + + @PatchMapping("/{messageId}") + public ResponseEntity updateMessage( + @PathVariable IrisMessageIdentifier messageId, + @RequestBody @Valid IrisMessageUpdateDto irisMessageUpdate, + BindingResult bindingResult) { + this.validateConstraints(bindingResult); + this.validateIrisMessageUpdate(irisMessageUpdate); + Optional optionalMessage = this.irisMessageService.findById(messageId); + if (optionalMessage.isPresent()) { + IrisMessage message = optionalMessage.get(); + message.setIsRead(irisMessageUpdate.getIsRead()); + IrisMessage updatedMessage = this.irisMessageService.saveMessage(message); + return ResponseEntity.ok(IrisMessageDetailsDto.fromEntity(updatedMessage)); + } + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + + private void validateIrisMessageUpdate(IrisMessageUpdateDto irisMessageUpdate) { + if (irisMessageUpdate == null || irisMessageUpdate.getIsRead() == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, ErrorMessages.INVALID_INPUT); + } + } + + @GetMapping("/folders") + public ResponseEntity> getMessageFolders() { + List irisMessageFolders = irisMessageService.getFolders(); + // there should always be at least one inbox and one outbox folder. No folders at all = error + if (irisMessageFolders.isEmpty()) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + return ResponseEntity.ok(IrisMessageFolderDto.fromEntity(irisMessageFolders)); + } + + @GetMapping("/files/{id}/download") + public ResponseEntity downloadMessageFile(@PathVariable IrisMessageFile.IrisMessageFileIdentifier id) { + Optional file = this.irisMessageService.findFileById(id); + if (file.isPresent()) { + try { + IrisMessageFile messageFile = file.get(); + int contentLength = 0; + MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM; + if (messageFile.getContent() != null) { + contentLength = messageFile.getContent().length; + Tika tika = new Tika(); + String contentType = tika.detect(messageFile.getContent(), messageFile.getName()); + mediaType = MediaType.valueOf(contentType); + } + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + messageFile.getName() + "\"") + .header(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, HttpHeaders.CONTENT_DISPOSITION) + .contentLength(contentLength) + .contentType(mediaType) + .body(messageFile.getContent()); + } catch(Throwable e) { + throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "iris_message.invalid_file"); + } + } + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + + @GetMapping("/hd-contacts") + public ResponseEntity> getMessageHdContacts( + @Valid @Size(max = 100) @RequestParam(required = false) String search, + @RequestParam(defaultValue = "false") boolean includeOwn) { + validateField(search, FIELD_SEARCH); + try { + ArrayList irisMessageContacts = new ArrayList<>(irisMessageService.getHdContacts(search)); + if (includeOwn) { + irisMessageContacts.add(irisMessageService.getOwnHdContact()); + } + return ResponseEntity.ok(irisMessageContacts); + } catch (Throwable e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "iris_message.missing_hd_contacts"); + } + } + + @GetMapping("/count/unread") + public ResponseEntity getUnreadMessageCount( + @RequestParam(required = false) IrisMessageFolderIdentifier folder) { + if (folder == null) { + return ResponseEntity.ok(irisMessageService.getCountUnread()); + } + return ResponseEntity.ok(irisMessageService.getCountUnreadByFolderId(folder)); + } + + private void validateConstraints(BindingResult bindingResult) { + if (bindingResult.hasErrors()) { + String message = ErrorMessages.INVALID_INPUT + ": " + bindingResult.getFieldErrors().stream() + .map(fieldError -> String.format("%s: %s", fieldError.getField(), fieldError.getDefaultMessage())) + .collect(Collectors.joining(", ")); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + } + + private void validateField(String value, String field) { + if (validationHelper.isPossibleAttack(value, field, false)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, ErrorMessages.INVALID_INPUT); + } + } +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageDetailsDto.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageDetailsDto.java new file mode 100644 index 000000000..afa20146d --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageDetailsDto.java @@ -0,0 +1,65 @@ +package iris.client_bff.iris_messages.web; + +import iris.client_bff.iris_messages.IrisMessage; +import iris.client_bff.iris_messages.IrisMessageFile; +import iris.client_bff.iris_messages.IrisMessageHdContact; +import lombok.*; +import org.apache.tika.Tika; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Value +public class IrisMessageDetailsDto { + + private String id; + private String subject; + private String body; + private IrisMessageHdContact hdAuthor; + private IrisMessageHdContact hdRecipient; + private Instant createdAt; + private Boolean isRead; + private List fileAttachments; + private Boolean hasFileAttachments; + + public static IrisMessageDetailsDto fromEntity(IrisMessage message) { + List fileAttachments = FileAttachment.fromEntity(message.getFileAttachments()); + return new IrisMessageDetailsDto( + message.getId().toString(), + message.getSubject(), + message.getBody(), + message.getHdAuthor(), + message.getHdRecipient(), + message.getMetadata().getCreated(), + message.getIsRead(), + fileAttachments, + fileAttachments.size() > 0 + ); + } + + @Value + public static class FileAttachment { + + private String id; + private String name; + private String type; + + public static List fromEntity(List files) { + if (files == null) return new ArrayList<>(); + return files.stream().map(FileAttachment::fromEntity).collect(Collectors.toList()); + } + + public static FileAttachment fromEntity(IrisMessageFile file) { + Tika tika = new Tika(); + String type = tika.detect(file.getContent(), file.getName()); + return new FileAttachment( + file.getId().toString(), + file.getName(), + type + ); + } + } + +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageFolderDto.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageFolderDto.java new file mode 100644 index 000000000..6e0a59a21 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageFolderDto.java @@ -0,0 +1,60 @@ +package iris.client_bff.iris_messages.web; + +import iris.client_bff.iris_messages.IrisMessageContext; +import iris.client_bff.iris_messages.IrisMessageFolder; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class IrisMessageFolderDto { + + private String id; + private String name; + private IrisMessageContext context; + private List items; + private Boolean isDefault; + + private static IrisMessageFolderDto findParentFolderDto(List folderDtoList, IrisMessageFolder folder) { + if (folder.getParentFolder() == null) { + return null; + } + for ( IrisMessageFolderDto folderDto : folderDtoList ) { + if(folderDto.getId().equals(folder.getParentFolder().toString())) { + return folderDto; + } + if(!folderDto.items.isEmpty()) { + IrisMessageFolderDto parentFolderDto = IrisMessageFolderDto.findParentFolderDto(folderDto.items, folder); + if (parentFolderDto != null) { + return parentFolderDto; + } + } + } + return null; + } + + public static IrisMessageFolderDto fromEntity(IrisMessageFolder folder) { + return new IrisMessageFolderDto() + .setId(folder.getId().toString()) + .setName(folder.getName()) + .setContext(folder.getContext()) + .setIsDefault(folder.getIsDefault()) + .setItems(new ArrayList<>()); + } + + public static List fromEntity(List folderList) { + List folderDtoList = new ArrayList<>(); + for ( IrisMessageFolder folder : folderList ) { + IrisMessageFolderDto folderDto = IrisMessageFolderDto.fromEntity(folder); + IrisMessageFolderDto parentFolderDto = IrisMessageFolderDto.findParentFolderDto(folderDtoList, folder); + if (parentFolderDto != null) { + parentFolderDto.items.add(folderDto); + } else { + folderDtoList.add( folderDto ); + } + } + return folderDtoList; + } + +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageInsertDto.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageInsertDto.java new file mode 100644 index 000000000..2cf0f80aa --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageInsertDto.java @@ -0,0 +1,31 @@ +package iris.client_bff.iris_messages.web; + +import iris.client_bff.iris_messages.IrisMessage; +import iris.client_bff.iris_messages.IrisMessageHdContact; +import iris.client_bff.iris_messages.validation.FileTypeConstraint; +import iris.client_bff.iris_messages.validation.IrisMessageFileConstraint; +import lombok.Data; + +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; +import java.util.List; + +@Data +public class IrisMessageInsertDto { + + @NotBlank + @Size(max = IrisMessageHdContact.ID_MAX_LENGTH) + private String hdRecipient; + + @NotBlank + @Size(max = IrisMessage.SUBJECT_MAX_LENGTH) + private String subject; + + @NotBlank + @Size(max = IrisMessage.BODY_MAX_LENGTH) + private String body; + + @Valid + private List<@IrisMessageFileConstraint @FileTypeConstraint IrisMessageInsertFileDto> fileAttachments; +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageInsertFileDto.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageInsertFileDto.java new file mode 100644 index 000000000..6323c3a93 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageInsertFileDto.java @@ -0,0 +1,18 @@ +package iris.client_bff.iris_messages.web; + +import iris.client_bff.iris_messages.IrisMessageFile; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +@Data +public class IrisMessageInsertFileDto { + + @NotBlank + @Size(max = IrisMessageFile.NAME_MAX_LENGTH) + private String name; + + @NotBlank + private String content; +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageListItemDto.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageListItemDto.java new file mode 100644 index 000000000..4a522f97a --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageListItemDto.java @@ -0,0 +1,32 @@ +package iris.client_bff.iris_messages.web; + +import iris.client_bff.iris_messages.IrisMessage; +import iris.client_bff.iris_messages.IrisMessageHdContact; +import lombok.Value; + +import java.time.Instant; + +@Value +public class IrisMessageListItemDto { + + private String id; + private String subject; + private IrisMessageHdContact hdAuthor; + private IrisMessageHdContact hdRecipient; + private Instant createdAt; + private Boolean isRead; + private Boolean hasFileAttachments; + + public static IrisMessageListItemDto fromEntity(IrisMessage message) { + Boolean hasFileAttachments = message.getFileAttachments() != null && message.getFileAttachments().size() > 0; + return new IrisMessageListItemDto( + message.getId().toString(), + message.getSubject(), + message.getHdAuthor(), + message.getHdRecipient(), + message.getMetadata().getCreated(), + message.getIsRead(), + hasFileAttachments + ); + } +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageUpdateDto.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageUpdateDto.java new file mode 100644 index 000000000..50c0a07e5 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/web/IrisMessageUpdateDto.java @@ -0,0 +1,17 @@ +package iris.client_bff.iris_messages.web; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotNull; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class IrisMessageUpdateDto { + + @NotNull + private Boolean isRead; + +} diff --git a/iris-client-bff/src/main/resources/db/migration/V1009__add_iris_messages.sql b/iris-client-bff/src/main/resources/db/migration/V1009__add_iris_messages.sql new file mode 100644 index 000000000..e8e410f18 --- /dev/null +++ b/iris-client-bff/src/main/resources/db/migration/V1009__add_iris_messages.sql @@ -0,0 +1,38 @@ +CREATE TABLE iris_message_folder ( + id uuid NOT NULL, + name varchar(255) NOT NULL, + parent_folder uuid NULL, + context varchar(50) NOT NULL, + created timestamp NOT NULL, + last_modified timestamp NOT NULL, + created_by uuid NULL, + last_modified_by uuid NULL, + CONSTRAINT iris_message_folder_pkey PRIMARY KEY (id), + FOREIGN KEY (created_by) REFERENCES user_accounts(user_id), + FOREIGN KEY (last_modified_by) REFERENCES user_accounts(user_id) +); + +CREATE TABLE iris_message_folder_default ( + id uuid NOT NULL, + CONSTRAINT iris_message_folder_default_pkey PRIMARY KEY (id) +); + +CREATE TABLE iris_message ( + id uuid NOT NULL, + folder_id uuid NOT NULL, + subject varchar(500) NOT NULL, + body varchar(6000) NOT NULL, + hd_author_id varchar(255) NOT NULL, + hd_author_name varchar(255) NOT NULL, + hd_recipient_id varchar(255) NOT NULL, + hd_recipient_name varchar(255) NOT NULL, + is_read bool NULL, + created timestamp NOT NULL, + last_modified timestamp NOT NULL, + created_by uuid NULL, + last_modified_by uuid NULL, + CONSTRAINT iris_message_pkey PRIMARY KEY (id), + CONSTRAINT iris_message_folder_fk FOREIGN KEY (folder_id) REFERENCES iris_message_folder(id), + FOREIGN KEY (created_by) REFERENCES user_accounts(user_id), + FOREIGN KEY (last_modified_by) REFERENCES user_accounts(user_id) +); diff --git a/iris-client-bff/src/main/resources/db/migration/V1011__add_iris_message_files.sql b/iris-client-bff/src/main/resources/db/migration/V1011__add_iris_message_files.sql new file mode 100644 index 000000000..2851cb550 --- /dev/null +++ b/iris-client-bff/src/main/resources/db/migration/V1011__add_iris_message_files.sql @@ -0,0 +1,14 @@ +CREATE TABLE iris_message_file ( + id uuid NOT NULL, + message_id uuid NOT NULL, + name varchar(255) NOT NULL, + content bytea NULL, + created timestamp NOT NULL, + last_modified timestamp NOT NULL, + created_by uuid NULL, + last_modified_by uuid NULL, + CONSTRAINT iris_message_file_pkey PRIMARY KEY (id), + CONSTRAINT iris_message_file_message_fk FOREIGN KEY (message_id) REFERENCES iris_message(id), + FOREIGN KEY (created_by) REFERENCES user_accounts(user_id), + FOREIGN KEY (last_modified_by) REFERENCES user_accounts(user_id) +); diff --git a/iris-client-bff/src/main/resources/db/migration_mssql/V1009__add_iris_messages.sql b/iris-client-bff/src/main/resources/db/migration_mssql/V1009__add_iris_messages.sql new file mode 100644 index 000000000..3cecf61c3 --- /dev/null +++ b/iris-client-bff/src/main/resources/db/migration_mssql/V1009__add_iris_messages.sql @@ -0,0 +1,38 @@ +CREATE TABLE iris_message_folder ( + id binary(255) NOT NULL, + name varchar(255) NOT NULL, + parent_folder binary(255) NULL, + context varchar(50) NOT NULL, + created datetime2 NOT NULL, + last_modified datetime2 NOT NULL, + created_by binary(255) NULL, + last_modified_by binary(255) NULL, + CONSTRAINT iris_message_folder_pkey PRIMARY KEY (id), + FOREIGN KEY (created_by) REFERENCES user_accounts(user_id), + FOREIGN KEY (last_modified_by) REFERENCES user_accounts(user_id) +); + +CREATE TABLE iris_message_folder_default ( + id binary(255) NOT NULL, + CONSTRAINT iris_message_folder_default_pkey PRIMARY KEY (id) +); + +CREATE TABLE iris_message ( + id binary(255) NOT NULL, + folder_id binary(255) NOT NULL, + subject varchar(500) NOT NULL, + body varchar(6000) NOT NULL, + hd_author_id varchar(255) NOT NULL, + hd_author_name varchar(255) NOT NULL, + hd_recipient_id varchar(255) NOT NULL, + hd_recipient_name varchar(255) NOT NULL, + is_read bit NULL, + created datetime2 NOT NULL, + last_modified datetime2 NOT NULL, + created_by binary(255) NULL, + last_modified_by binary(255) NULL, + CONSTRAINT iris_message_pkey PRIMARY KEY (id), + CONSTRAINT iris_message_folder_fk FOREIGN KEY (folder_id) REFERENCES iris_message_folder(id), + FOREIGN KEY (created_by) REFERENCES user_accounts(user_id), + FOREIGN KEY (last_modified_by) REFERENCES user_accounts(user_id) +); diff --git a/iris-client-bff/src/main/resources/db/migration_mssql/V1011__add_iris_message_files.sql b/iris-client-bff/src/main/resources/db/migration_mssql/V1011__add_iris_message_files.sql new file mode 100644 index 000000000..e24f0785a --- /dev/null +++ b/iris-client-bff/src/main/resources/db/migration_mssql/V1011__add_iris_message_files.sql @@ -0,0 +1,15 @@ + +CREATE TABLE iris_message_file ( + id binary(255) NOT NULL, + message_id binary(255) NOT NULL, + name varchar(255) NOT NULL, + content varbinary(max) NULL, + created datetime2 NOT NULL, + last_modified datetime2 NOT NULL, + created_by binary(255) NULL, + last_modified_by binary(255) NULL, + CONSTRAINT iris_message_file_pkey PRIMARY KEY (id), + CONSTRAINT iris_message_file_message_fk FOREIGN KEY (message_id) REFERENCES iris_message(id), + FOREIGN KEY (created_by) REFERENCES user_accounts(user_id), + FOREIGN KEY (last_modified_by) REFERENCES user_accounts(user_id) +); diff --git a/iris-client-bff/src/main/resources/db/migration_mysql/V1009__add_iris_messages.sql b/iris-client-bff/src/main/resources/db/migration_mysql/V1009__add_iris_messages.sql new file mode 100644 index 000000000..a8eea81f9 --- /dev/null +++ b/iris-client-bff/src/main/resources/db/migration_mysql/V1009__add_iris_messages.sql @@ -0,0 +1,38 @@ +CREATE TABLE iris_message_folder ( + id binary(16) NOT NULL, + name varchar(255) NOT NULL, + parent_folder binary(16) NULL, + context varchar(50) NOT NULL, + created datetime NOT NULL, + last_modified datetime NOT NULL, + created_by binary(16) NULL, + last_modified_by binary(16) NULL, + CONSTRAINT iris_message_folder_pkey PRIMARY KEY (id), + FOREIGN KEY (created_by) REFERENCES user_accounts(user_id), + FOREIGN KEY (last_modified_by) REFERENCES user_accounts(user_id) +); + +CREATE TABLE iris_message_folder_default ( + id binary(16) NOT NULL, + CONSTRAINT iris_message_folder_default_pkey PRIMARY KEY (id) +); + +CREATE TABLE iris_message ( + id binary(16) NOT NULL, + folder_id binary(16) NOT NULL, + subject varchar(500) NOT NULL, + body varchar(6000) NOT NULL, + hd_author_id varchar(255) NOT NULL, + hd_author_name varchar(255) NOT NULL, + hd_recipient_id varchar(255) NOT NULL, + hd_recipient_name varchar(255) NOT NULL, + is_read bool NULL, + created datetime NOT NULL, + last_modified datetime NOT NULL, + created_by binary(16) NULL, + last_modified_by binary(16) NULL, + CONSTRAINT iris_message_pkey PRIMARY KEY (id), + CONSTRAINT iris_message_folder_fk FOREIGN KEY (folder_id) REFERENCES iris_message_folder(id), + FOREIGN KEY (created_by) REFERENCES user_accounts(user_id), + FOREIGN KEY (last_modified_by) REFERENCES user_accounts(user_id) +); diff --git a/iris-client-bff/src/main/resources/db/migration_mysql/V1011__add_iris_message_files.sql b/iris-client-bff/src/main/resources/db/migration_mysql/V1011__add_iris_message_files.sql new file mode 100644 index 000000000..c70e1202a --- /dev/null +++ b/iris-client-bff/src/main/resources/db/migration_mysql/V1011__add_iris_message_files.sql @@ -0,0 +1,14 @@ +CREATE TABLE iris_message_file ( + id binary(16) NOT NULL, + message_id binary(16) NOT NULL, + name varchar(255) NOT NULL, + content mediumblob NULL, + created datetime NOT NULL, + last_modified datetime NOT NULL, + created_by binary(16) NULL, + last_modified_by binary(16) NULL, + CONSTRAINT iris_message_file_pkey PRIMARY KEY (id), + CONSTRAINT iris_message_file_message_fk FOREIGN KEY (message_id) REFERENCES iris_message(id), + FOREIGN KEY (created_by) REFERENCES user_accounts(user_id), + FOREIGN KEY (last_modified_by) REFERENCES user_accounts(user_id) +); diff --git a/iris-client-bff/src/main/resources/messages.properties b/iris-client-bff/src/main/resources/messages.properties index 708ce7b8d..f13f20b0b 100644 --- a/iris-client-bff/src/main/resources/messages.properties +++ b/iris-client-bff/src/main/resources/messages.properties @@ -17,3 +17,12 @@ app.status.connection_closed_by_remote=Eine vorhandene Verbindung wurde vom Remo app.status.access_denied=Es konnte keine Verbindung hergestellt werden, da der Zielcomputer die Verbindung verweigerte. app.status.no_such_host=Der Host des App Anbieters kann nicht ermittelt werden. app.status.timeout=Es gab eine Zeitüberschreitung bei der Anfrage. + +iris_message.submission_error=Fehler: Nachricht konnte nicht gesendet werden +iris_message.invalid_id=Die Nachrichten ID ist ungültig. +iris_message.invalid_recipient=Der Empfänger der Nachricht ist ungültig. +iris_message.missing_hd_contacts=Die Gesundheitsamt-Kontakte konnten nicht geladen werden. +iris_message.invalid_folder=Der Nachrichten-Ordner ist ungültig. +iris_message.invalid_file=Der Nachrichten Anhang ist ungültig. +iris_message.invalid_file_type=Der Dateityp ist ungültig. +iris_message.max_upload_file_size=Die Maximalgröße für Dateien von {0} wurde überschritten. diff --git a/iris-client-bff/src/main/resources/messages_de.properties b/iris-client-bff/src/main/resources/messages_de.properties index 708ce7b8d..f13f20b0b 100644 --- a/iris-client-bff/src/main/resources/messages_de.properties +++ b/iris-client-bff/src/main/resources/messages_de.properties @@ -17,3 +17,12 @@ app.status.connection_closed_by_remote=Eine vorhandene Verbindung wurde vom Remo app.status.access_denied=Es konnte keine Verbindung hergestellt werden, da der Zielcomputer die Verbindung verweigerte. app.status.no_such_host=Der Host des App Anbieters kann nicht ermittelt werden. app.status.timeout=Es gab eine Zeitüberschreitung bei der Anfrage. + +iris_message.submission_error=Fehler: Nachricht konnte nicht gesendet werden +iris_message.invalid_id=Die Nachrichten ID ist ungültig. +iris_message.invalid_recipient=Der Empfänger der Nachricht ist ungültig. +iris_message.missing_hd_contacts=Die Gesundheitsamt-Kontakte konnten nicht geladen werden. +iris_message.invalid_folder=Der Nachrichten-Ordner ist ungültig. +iris_message.invalid_file=Der Nachrichten Anhang ist ungültig. +iris_message.invalid_file_type=Der Dateityp ist ungültig. +iris_message.max_upload_file_size=Die Maximalgröße für Dateien von {0} wurde überschritten. diff --git a/iris-client-bff/src/main/resources/messages_en.properties b/iris-client-bff/src/main/resources/messages_en.properties index e28b429de..fa936f6d3 100644 --- a/iris-client-bff/src/main/resources/messages_en.properties +++ b/iris-client-bff/src/main/resources/messages_en.properties @@ -17,3 +17,12 @@ app.status.connection_closed_by_remote=An existing connection has been closed by app.status.access_denied=A connection could not be established because the target computer refused to connect. app.status.no_such_host=The host of the app provider cannot be determined. app.status.timeout=The request timed out. + +iris_message.submission_error=Error: Message could not be sent +iris_message.invalid_id=The message ID is invalid. +iris_message.invalid_recipient=The recipient of the message is invalid. +iris_message.missing_hd_contacts=The health department contacts could not be loaded. +iris_message.invalid_folder=The message folder is invalid. +iris_message.invalid_file=The message attachment is invalid. +iris_message.invalid_file_type=The file type is invalid. +iris_message.max_upload_file_size=The maximum file size of {0} has been exceeded. diff --git a/iris-client-bff/src/test/java/iris/client_bff/core/mail/EmailSenderIntegrationTests.java b/iris-client-bff/src/test/java/iris/client_bff/core/mail/EmailSenderIntegrationTests.java index 6fdcb30ef..321875d81 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/core/mail/EmailSenderIntegrationTests.java +++ b/iris-client-bff/src/test/java/iris/client_bff/core/mail/EmailSenderIntegrationTests.java @@ -112,7 +112,7 @@ Try sendTestEmailEn() { Try sendTestEmailDe() { - var locale = Locale.GERMAN; + var locale = new Locale("de", "DE", "test"); var subject = messages.getMessage("TestMail.subject", locale); var email = new TestEmail(subject, TestKeys.TEST_MAIL_FTL, getParameters(), locale); @@ -122,7 +122,7 @@ Try sendTestEmailDe() { Try sendTestHtmlEmailDe() { - var locale = Locale.GERMAN; + var locale = new Locale("de", "DE", "test"); var subject = messages.getMessage("TestMail.subject", locale); var email = new TestEmail(subject, TestKeys.TEST_HTML_MAIL_FTLH, getParameters(), locale); diff --git a/iris-client-bff/src/test/java/iris/client_bff/dbms/DatabasesystemIT.java b/iris-client-bff/src/test/java/iris/client_bff/dbms/DatabasesystemIT.java index 5a709913d..3464afbf3 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/dbms/DatabasesystemIT.java +++ b/iris-client-bff/src/test/java/iris/client_bff/dbms/DatabasesystemIT.java @@ -12,8 +12,14 @@ import iris.client_bff.events.EventDataRequestService; import iris.client_bff.events.EventDataRequestsDataInitializer; import iris.client_bff.events.EventDataSubmissionRepository; +import iris.client_bff.iris_messages.IrisMessageContext; +import iris.client_bff.iris_messages.IrisMessageFolder; +import iris.client_bff.iris_messages.IrisMessageFolderRepository; +import iris.client_bff.iris_messages.IrisMessageService; import java.time.Instant; +import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -36,6 +42,10 @@ abstract class DatabasesystemIT { private EventDataRequestService eventReqService; @Autowired private CaseDataRequestService caseReqService; + @Autowired + private IrisMessageService irisMessageService; + @Autowired + private IrisMessageFolderRepository irisMessageFolderRepository; @Test void eventRequests() { @@ -116,4 +126,31 @@ void caseSubmissions() { assertThat(caseSubmissions.findAllByRequest(CaseDataRequestDataInitializer.DATA_REQUEST_1).toList()).hasSize(1); } + @Test + void irisMessages() { + + List inboxRootFolders = irisMessageFolderRepository.findAll(); + assertThat(inboxRootFolders).hasSize(3); + + Optional inboxRootFolder = irisMessageFolderRepository.findFirstByContextAndParentFolderIsNull(IrisMessageContext.INBOX); + assertThat(inboxRootFolder.isPresent()).isTrue(); + assertThat(irisMessageService.search(inboxRootFolder.get().getId(), null, null).toList()).hasSize(2); + + Optional outboxRootFolder = irisMessageFolderRepository.findFirstByContextAndParentFolderIsNull(IrisMessageContext.OUTBOX); + assertThat(outboxRootFolder.isPresent()).isTrue(); + assertThat(irisMessageService.search(outboxRootFolder.get().getId(), null, null).toList()).hasSize(1); + + List inboxNestedFolders = irisMessageFolderRepository.findAllByParentFolder(inboxRootFolder.get().getId()); + assertThat(inboxNestedFolders).hasSize(1); + + assertThat(irisMessageService.search(inboxNestedFolders.get(0).getId(), null, null).toList()).hasSize(1); + + assertThat(irisMessageService.getCountUnread()).isEqualTo(3); + + assertThat(irisMessageService.getCountUnreadByFolderId(inboxRootFolder.get().getId())).isEqualTo(2); + assertThat(irisMessageService.getCountUnreadByFolderId(outboxRootFolder.get().getId())).isEqualTo(0); + assertThat(irisMessageService.getCountUnreadByFolderId(inboxNestedFolders.get(0).getId())).isEqualTo(1); + + } + } diff --git a/iris-client-bff/src/test/java/iris/client_bff/iris_messages/IrisMessageBuilderTest.java b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/IrisMessageBuilderTest.java new file mode 100644 index 000000000..acdcf2a6f --- /dev/null +++ b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/IrisMessageBuilderTest.java @@ -0,0 +1,85 @@ +package iris.client_bff.iris_messages; + +import iris.client_bff.iris_messages.eps.EPSIrisMessageClient; +import iris.client_bff.iris_messages.eps.IrisMessageTransferDto; +import iris.client_bff.iris_messages.web.IrisMessageInsertDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.support.MessageSourceAccessor; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class IrisMessageBuilderTest { + + IrisMessageTestData testData; + + @Mock + IrisMessageFolderRepository folderRepository; + + @Mock + EPSIrisMessageClient irisMessageClient; + + @Mock + MessageSourceAccessor messages; + + IrisMessageBuilder builder; + + @BeforeEach + void setUp() { + this.testData = new IrisMessageTestData(); + this.builder = new IrisMessageBuilder(this.folderRepository, this.irisMessageClient, this.messages); + } + + @Test + void buildTransfer() { + + IrisMessage message = this.testData.MOCK_INBOX_MESSAGE; + IrisMessageTransferDto messageTransfer = IrisMessageTransferDto.fromEntity(message); + + when(this.folderRepository.findFirstByContextAndParentFolderIsNull(any())).thenReturn(Optional.of(this.testData.MOCK_INBOX_FOLDER)); + + when(this.irisMessageClient.getOwnIrisMessageHdContact()).thenReturn(this.testData.MOCK_CONTACT_OWN); + + var builtMessage = this.builder.build(messageTransfer); + + verify(this.folderRepository).findFirstByContextAndParentFolderIsNull(any()); + + verify(this.irisMessageClient).getOwnIrisMessageHdContact(); + + // messages should be identical except ID: toString removes the ID + assertEquals(message.toString(), builtMessage.toString()); + + } + + @Test + void buildInsert() { + + IrisMessage message = this.testData.MOCK_OUTBOX_MESSAGE; + IrisMessageInsertDto messageInsert = this.testData.getTestMessageInsert(message); + + when(this.folderRepository.findFirstByContextAndParentFolderIsNull(any())).thenReturn(Optional.of(this.testData.MOCK_OUTBOX_FOLDER)); + + when(this.irisMessageClient.getOwnIrisMessageHdContact()).thenReturn(this.testData.MOCK_CONTACT_OWN); + when(this.irisMessageClient.findIrisMessageHdContactById(any(String.class))).thenReturn(Optional.of(this.testData.MOCK_CONTACT_OTHER)); + + var builtMessage = this.builder.build(messageInsert); + + verify(this.folderRepository).findFirstByContextAndParentFolderIsNull(any()); + + verify(this.irisMessageClient).getOwnIrisMessageHdContact(); + verify(this.irisMessageClient).findIrisMessageHdContactById(any(String.class)); + + // messages should be identical except ID: toString removes the ID + assertEquals(message.toString(), builtMessage.toString()); + + } + +} diff --git a/iris-client-bff/src/test/java/iris/client_bff/iris_messages/IrisMessageDataInitializer.java b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/IrisMessageDataInitializer.java new file mode 100644 index 000000000..c58d48e20 --- /dev/null +++ b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/IrisMessageDataInitializer.java @@ -0,0 +1,56 @@ +package iris.client_bff.iris_messages; + +import static org.assertj.core.api.Assertions.*; + +import iris.client_bff.DataInitializer; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.Optional; + +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +@Order(10) +public class IrisMessageDataInitializer implements DataInitializer { + + private final IrisMessageRepository messageRepository; + + private final IrisMessageFolderRepository folderRepository; + + private final IrisMessageTestData testData; + + @Getter + private IrisMessageFolder inboxFolder; + + @Override + public void initialize() { + + log.debug("Test data: creating iris messages …"); + + Optional folder = this.folderRepository + .findFirstByContextAndParentFolderIsNull(IrisMessageContext.INBOX); + assertThat(folder).isPresent(); + this.inboxFolder = folder.get(); + IrisMessageFolder nestedInboxFolder = this.testData.getTestMessageFolder(inboxFolder, "nested inbox"); + + this.folderRepository.save(nestedInboxFolder); + + Optional outboxFolder = this.folderRepository + .findFirstByContextAndParentFolderIsNull(IrisMessageContext.OUTBOX); + assertThat(outboxFolder).isPresent(); + + var message = this.testData.getTestInboxMessage(inboxFolder); + message.setSubject("First test inbox subject"); + this.messageRepository.save(message); + this.messageRepository.save(this.testData.getTestInboxMessage(inboxFolder)); + + this.messageRepository.save(this.testData.getTestInboxMessage(nestedInboxFolder)); + + this.messageRepository.save(this.testData.getTestOutboxMessage(outboxFolder.get())); + } +} diff --git a/iris-client-bff/src/test/java/iris/client_bff/iris_messages/IrisMessageServiceTest.java b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/IrisMessageServiceTest.java new file mode 100644 index 000000000..7c736d5cf --- /dev/null +++ b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/IrisMessageServiceTest.java @@ -0,0 +1,225 @@ +package iris.client_bff.iris_messages; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import iris.client_bff.core.utils.HibernateSearcher; +import iris.client_bff.hd_search.eps.EPSHdSearchClient; +import iris.client_bff.iris_messages.IrisMessage.IrisMessageIdentifier; +import iris.client_bff.iris_messages.eps.EPSIrisMessageClient; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.AdditionalAnswers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +public class IrisMessageServiceTest { + + IrisMessageTestData testData; + + @Mock + IrisMessageRepository messageRepository; + + @Mock + IrisMessageFolderRepository folderRepository; + + @Mock + IrisMessageFileRepository fileRepository; + + @Mock + HibernateSearcher searcher; + + @Mock + EPSIrisMessageClient irisMessageClient; + + @Mock + EPSHdSearchClient hdSearchClient; + + IrisMessageService service; + + private final IrisMessageIdentifier ID_NOT_FOUND = IrisMessageIdentifier.of(UUID.randomUUID()); + + private final IrisMessageFile.IrisMessageFileIdentifier FILE_ID_NOT_FOUND = IrisMessageFile.IrisMessageFileIdentifier.of(UUID.randomUUID()); + + @BeforeEach + void setUp() { + this.testData = new IrisMessageTestData(); + this.service = new IrisMessageService( + this.messageRepository, + this.folderRepository, + this.fileRepository, + this.searcher, + this.irisMessageClient, + this.hdSearchClient); + } + + @Test + void findById() { + when(this.messageRepository.findById(any())).thenReturn(Optional.of(this.testData.MOCK_INBOX_MESSAGE)); + + var message = this.service.findById(this.testData.MOCK_INBOX_MESSAGE.getId()); + + verify(this.messageRepository).findById(any()); + + assertTrue(message.isPresent()); + assertEquals(message.get(), this.testData.MOCK_INBOX_MESSAGE); + } + + @Test + void findById_notFound() { + when(this.messageRepository.findById(this.ID_NOT_FOUND)) + .thenReturn(Optional.empty()); + + var message = this.service.findById(this.ID_NOT_FOUND); + + verify(this.messageRepository).findById(any()); + + assertTrue(message.isEmpty()); + } + + @Test + void search() { + + IrisMessage message = this.testData.MOCK_INBOX_MESSAGE; + + var folderId = message.getFolder().getId(); + + Page page = new PageImpl<>(List.of(message)); + + when(this.messageRepository.findAllByFolderIdOrderByIsReadAsc(eq(folderId), nullable(Pageable.class))) + .thenReturn(page); + + var messagePage = this.service.search(folderId, null, null); + + verify(this.messageRepository).findAllByFolderIdOrderByIsReadAsc(eq(folderId), nullable(Pageable.class)); + + assertEquals(1, messagePage.getContent().size()); + assertEquals(page.getContent(), messagePage.getContent()); + } + + @Test + void getCountUnreadByFolderId() { + + var folderId = this.testData.MOCK_INBOX_FOLDER.getId(); + + when(this.messageRepository.getCountUnreadByFolderId(any(IrisMessageFolder.IrisMessageFolderIdentifier.class))) + .thenReturn(3); + + var count = this.service.getCountUnreadByFolderId(folderId); + + verify(this.messageRepository).getCountUnreadByFolderId(eq(folderId)); + + assertEquals(3, count); + + } + + @Test + void getCountUnread() { + + when(this.messageRepository.countByIsReadFalseOrIsReadIsNull()).thenReturn(3); + + var count = this.service.getCountUnread(); + + verify(this.messageRepository).countByIsReadFalseOrIsReadIsNull(); + + assertEquals(3, count); + + } + + @Test + void getFolders() { + + when(this.folderRepository.findAll()) + .thenReturn(List.of(this.testData.MOCK_INBOX_FOLDER, this.testData.MOCK_OUTBOX_FOLDER)); + + var folders = this.service.getFolders(); + + verify(this.folderRepository).findAll(); + + assertEquals(2, folders.size()); + + } + + @Test + void findFileById() { + + when(this.fileRepository.findById(any())).thenReturn(Optional.of(this.testData.MOCK_MESSAGE_FILE)); + + var file = this.service.findFileById(this.testData.MOCK_MESSAGE_FILE.getId()); + + verify(this.fileRepository).findById(any()); + + assertTrue(file.isPresent()); + assertEquals(file.get(), this.testData.MOCK_MESSAGE_FILE); + + } + + @Test + void findFileById_notFound() { + + when(this.fileRepository.findById(this.FILE_ID_NOT_FOUND)).thenReturn(Optional.empty()); + + var file = this.service.findFileById(this.FILE_ID_NOT_FOUND); + + verify(this.fileRepository).findById(any()); + + assertTrue(file.isEmpty()); + + } + + @Test + void getHdContacts() { + + when(this.irisMessageClient.getIrisMessageHdContacts()).thenReturn(List.of(this.testData.MOCK_CONTACT_OTHER)); + + var contacts = this.service.getHdContacts(null); + + verify(this.irisMessageClient).getIrisMessageHdContacts(); + + assertEquals(contacts.size(), 1); + assertEquals(contacts.get(0), this.testData.MOCK_CONTACT_OTHER); + + } + + @Test + void getOwnHdContact() { + + when(this.irisMessageClient.getOwnIrisMessageHdContact()).thenReturn(this.testData.MOCK_CONTACT_OWN); + + var contact = this.service.getOwnHdContact(); + + verify(this.irisMessageClient).getOwnIrisMessageHdContact(); + + assertEquals(contact, this.testData.MOCK_CONTACT_OWN); + + } + + @Test + void sendMessage() { + + IrisMessage message = this.testData.MOCK_OUTBOX_MESSAGE; + + doNothing().when(this.irisMessageClient).createIrisMessage(any(IrisMessage.class)); + when(this.messageRepository.save(any(IrisMessage.class))).then(AdditionalAnswers.returnsFirstArg()); + + var sentMessage = this.service.sendMessage(message); + + verify(this.irisMessageClient).createIrisMessage(message); + verify(this.messageRepository).save(message); + + assertEquals(sentMessage, message); + + } + +} diff --git a/iris-client-bff/src/test/java/iris/client_bff/iris_messages/IrisMessageTestData.java b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/IrisMessageTestData.java new file mode 100644 index 000000000..3dff5e963 --- /dev/null +++ b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/IrisMessageTestData.java @@ -0,0 +1,100 @@ +package iris.client_bff.iris_messages; + +import iris.client_bff.iris_messages.web.IrisMessageInsertDto; +import org.springframework.stereotype.Component; + +@Component +public class IrisMessageTestData { + + public final IrisMessageFile MOCK_MESSAGE_FILE = getTestMessageFile(); + + private final IrisMessageFolder MOCK_DEFAULT_FOLDER = getTestDefaultMessageFolder(); + + public final IrisMessageFolder MOCK_INBOX_FOLDER = MOCK_DEFAULT_FOLDER; + public final IrisMessageFolder MOCK_OUTBOX_FOLDER = getTestMessageFolder(IrisMessageContext.OUTBOX, "outbox folder"); + + public final IrisMessageHdContact MOCK_CONTACT_OWN = getTestMessageHdContactOwn(); + public final IrisMessageHdContact MOCK_CONTACT_OTHER = getTestMessageHdContactOther(); + + public final IrisMessage MOCK_INBOX_MESSAGE = getTestInboxMessage(MOCK_INBOX_FOLDER); + public final IrisMessage MOCK_OUTBOX_MESSAGE = getTestOutboxMessage(MOCK_OUTBOX_FOLDER); + + public final static String INVALID_SUBJECT = "S".repeat(Math.max(0, IrisMessage.SUBJECT_MAX_LENGTH + 1)); + public final static String INVALID_BODY = "B".repeat(Math.max(0, IrisMessage.BODY_MAX_LENGTH + 1)); + + private IrisMessageFile getTestMessageFile() { + return new IrisMessageFile() + .setName("test-file-name") + .setContent("test".getBytes()); + } + + private IrisMessageHdContact getTestMessageHdContactOwn() { + return new IrisMessageHdContact() + .setName("test-own-contact") + .setId("test-own-contact-id") + .setIsOwn(true); + } + + private IrisMessageHdContact getTestMessageHdContactOther() { + return new IrisMessageHdContact() + .setName("test-other-contact") + .setId("test-other-contact-id") + .setIsOwn(false); + } + + private IrisMessageFolder getTestDefaultMessageFolder() { + IrisMessageFolder folder = new IrisMessageFolder() + .setContext(IrisMessageContext.INBOX) + .setName("default folder"); + folder.setDefaultFolder(folder.getId()); + return folder; + } + + public IrisMessageFolder getTestMessageFolder(IrisMessageFolder parentFolder, String name) { + return getTestMessageFolder(parentFolder.getContext(), name) + .setParentFolder(parentFolder.getId()); + } + + private IrisMessageFolder getTestMessageFolder(IrisMessageContext context, String name) { + return new IrisMessageFolder() + .setContext(context) + .setName(name) + .setDefaultFolder(MOCK_DEFAULT_FOLDER.getId()); + } + + public IrisMessage getTestInboxMessage() { + IrisMessageFolder folder = this.getTestMessageFolder(IrisMessageContext.INBOX, "inbox folder"); + return this.getTestInboxMessage(folder); + } + + public IrisMessage getTestOutboxMessage(IrisMessageFolder folder) { + IrisMessage message = new IrisMessage(); + message + .setSubject("Test outbox subject") + .setBody("Test outbox body") + .setFolder(folder) + .setHdAuthor(this.getTestMessageHdContactOwn()) + .setHdRecipient(this.getTestMessageHdContactOther()) + .setIsRead(true); + return message; + } + + public IrisMessage getTestInboxMessage(IrisMessageFolder folder) { + IrisMessage message = new IrisMessage(); + message + .setSubject("Test inbox subject") + .setBody("Test inbox body") + .setFolder(folder) + .setHdAuthor(this.getTestMessageHdContactOther()) + .setHdRecipient(this.getTestMessageHdContactOwn()) + .setIsRead(false); + return message; + } + + public IrisMessageInsertDto getTestMessageInsert(IrisMessage message) { + return new IrisMessageInsertDto() + .setSubject(message.getSubject()) + .setBody(message.getBody()) + .setHdRecipient(message.getHdRecipient().getId()); + } +} diff --git a/iris-client-bff/src/test/java/iris/client_bff/iris_messages/eps/IrisMessageDataControllerTest.java b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/eps/IrisMessageDataControllerTest.java new file mode 100644 index 000000000..8b65d23ab --- /dev/null +++ b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/eps/IrisMessageDataControllerTest.java @@ -0,0 +1,99 @@ +package iris.client_bff.iris_messages.eps; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import iris.client_bff.IrisWebIntegrationTest; +import iris.client_bff.iris_messages.IrisMessage; +import iris.client_bff.iris_messages.IrisMessageContext; +import iris.client_bff.iris_messages.IrisMessageException; +import iris.client_bff.iris_messages.IrisMessageFolder; +import iris.client_bff.iris_messages.IrisMessageFolderRepository; +import iris.client_bff.iris_messages.IrisMessageRepository; +import iris.client_bff.iris_messages.IrisMessageTestData; +import iris.client_bff.ui.messages.ErrorMessages; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.support.MessageSourceAccessor; + +@IrisWebIntegrationTest +class IrisMessageDataControllerTest { + + @Autowired + IrisMessageDataController dataController; + + @Autowired + IrisMessageRepository messageRepository; + + @Autowired + IrisMessageFolderRepository folderRepository; + + @Autowired + EPSIrisMessageClient messageClient; + + @Autowired + MessageSourceAccessor messages; + + @Test + void createIrisMessage() { + + IrisMessage localMessage = this.messageRepository.save(this.getMessage()); + IrisMessageTransferDto localMessageTransfer = IrisMessageTransferDto.fromEntity(localMessage); + + IrisMessageTransferDto remoteMessageTransfer = this.dataController.createIrisMessage(localMessageTransfer); + + assertNotNull(remoteMessageTransfer); + assertThat(localMessageTransfer).isEqualTo(remoteMessageTransfer); + } + + @Test + void createIrisMessage_shouldFail_noData() { + + var e = assertThrows(IrisMessageException.class, () -> this.dataController.createIrisMessage(null)); + + assertNotNull(e.getMessage()); + assertThat(e.getMessage()).contains(messages.getMessage("iris_message.invalid_id")); + } + + @Test + void createIrisMessage_shouldFail_invalidData() { + + IrisMessageTransferDto localMessageTransfer = Mockito.spy(IrisMessageTransferDto.fromEntity(this.getMessage())); + + localMessageTransfer.setSubject(IrisMessageTestData.INVALID_SUBJECT); + verify(localMessageTransfer).setSubject(IrisMessageTestData.INVALID_SUBJECT); + + localMessageTransfer.setBody(IrisMessageTestData.INVALID_BODY); + verify(localMessageTransfer).setBody(IrisMessageTestData.INVALID_BODY); + + var e = assertThrows(IrisMessageException.class, + () -> this.dataController.createIrisMessage(localMessageTransfer)); + + assertNotNull(e.getMessage()); + assertTrue(e.getMessage().contains(ErrorMessages.INVALID_INPUT)); + } + + private IrisMessage getMessage() { + + IrisMessageTestData testData = new IrisMessageTestData(); + + Optional outboxFolder = this.folderRepository + .findFirstByContextAndParentFolderIsNull(IrisMessageContext.OUTBOX); + + assertThat(outboxFolder.isPresent()).isTrue(); + + IrisMessage message = testData.getTestOutboxMessage(outboxFolder.get()); + + // @todo: remove next line as soon as dummy loopback functionality is removed / EPS message endpoints are + // implemented + message.setHdRecipient(this.messageClient.getOwnIrisMessageHdContact()); + + return message; + } + +} diff --git a/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageControllerIntegrationTest.java b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageControllerIntegrationTest.java new file mode 100644 index 000000000..415d22a5c --- /dev/null +++ b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageControllerIntegrationTest.java @@ -0,0 +1,45 @@ +package iris.client_bff.iris_messages.web; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.*; +import static org.hamcrest.Matchers.*; + +import io.restassured.http.ContentType; +import iris.client_bff.IrisWebIntegrationTest; +import iris.client_bff.iris_messages.IrisMessageDataInitializer; +import lombok.RequiredArgsConstructor; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +/** + * @author Jens Kutzsche + */ +@IrisWebIntegrationTest +@RequiredArgsConstructor +class IrisMessageControllerIntegrationTest { + + private final String baseUrl = "/iris-messages"; + + private final MockMvc mockMvc; + private final IrisMessageDataInitializer initializer; + + @Test + @WithMockUser() + @DisplayName("Tests getMessage to search with folder and search string") + void getMessage_WithFolderAndSearchString_ReturnsMessage() throws Exception { + + given().mockMvc(mockMvc) + .param("folder", initializer.getInboxFolder().getId().toString()) + .param("search", "First test") + .when() + .get(baseUrl) + .then() + .status(HttpStatus.OK) + .contentType(ContentType.JSON) + .body("content", hasSize(1)) + .body("content[0].subject", is("First test inbox subject")); + } +} diff --git a/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageControllerTest.java b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageControllerTest.java new file mode 100644 index 000000000..086e4cce1 --- /dev/null +++ b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageControllerTest.java @@ -0,0 +1,365 @@ +package iris.client_bff.iris_messages.web; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; + +import iris.client_bff.IrisWebIntegrationTest; +import iris.client_bff.RestResponsePage; +import iris.client_bff.events.web.TestData; +import iris.client_bff.iris_messages.IrisMessage; +import iris.client_bff.iris_messages.IrisMessage.IrisMessageIdentifier; +import iris.client_bff.iris_messages.IrisMessageBuilder; +import iris.client_bff.iris_messages.IrisMessageFile.IrisMessageFileIdentifier; +import iris.client_bff.iris_messages.IrisMessageFolder; +import iris.client_bff.iris_messages.IrisMessageFolder.IrisMessageFolderIdentifier; +import iris.client_bff.iris_messages.IrisMessageHdContact; +import iris.client_bff.iris_messages.IrisMessageService; +import iris.client_bff.iris_messages.IrisMessageTestData; +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +@IrisWebIntegrationTest +@RequiredArgsConstructor +class IrisMessageControllerTest { + + private final String baseUrl = "/iris-messages"; + + TypeReference> PAGE_TYPE = new TypeReference<>() {}; + + private final MockMvc mockMvc; + private final ObjectMapper om; + + private final IrisMessageTestData testData; + + @MockBean + private IrisMessageService irisMessageService; + + @MockBean + private IrisMessageBuilder irisMessageBuilder; + + @Test + void endpointShouldBeProtected() throws Exception { + mockMvc.perform(get(baseUrl)) + .andExpect(MockMvcResultMatchers.status().isForbidden()) + .andReturn(); + } + + @Test + @WithMockUser() + void getInboxMessages() throws Exception { + this.getMessages(testData.MOCK_INBOX_MESSAGE, testData.MOCK_INBOX_MESSAGE.getFolder().getId()); + } + + @Test + @WithMockUser() + void getOutboxMessages() throws Exception { + this.getMessages(testData.MOCK_OUTBOX_MESSAGE, testData.MOCK_OUTBOX_MESSAGE.getFolder().getId()); + } + + @Test + @WithMockUser() + void getMessages_shouldFail() throws Exception { + mockMvc.perform(get(baseUrl)) + .andExpect(MockMvcResultMatchers.status().is4xxClientError()) + .andReturn(); + } + + @Test + @WithMockUser() + public void createAndSendMessage() throws Exception { + IrisMessage irisMessage = testData.MOCK_OUTBOX_MESSAGE; + + IrisMessageInsertDto messageInsert = this.testData.getTestMessageInsert(irisMessage); + + when(irisMessageBuilder.build(any(IrisMessageInsertDto.class))).thenReturn(irisMessage); + + when(irisMessageService.sendMessage(any())).thenReturn(irisMessage); + when(irisMessageService.findById(irisMessage.getId())).thenReturn(Optional.of(irisMessage)); + + ObjectMapper objectMapper = new ObjectMapper(); + + var postResult = mockMvc + .perform( + MockMvcRequestBuilders.post(baseUrl) + .content(objectMapper.writeValueAsString(messageInsert)) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andReturn(); + + verify(irisMessageBuilder).build(messageInsert); + verify(irisMessageService).sendMessage(any()); + + String location = postResult.getResponse().getHeader("location"); + assert location != null; + String messageId = location.substring(location.lastIndexOf('/') + 1); + + var res = mockMvc.perform(get(baseUrl + "/" + messageId)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andReturn(); + + verify(irisMessageService).findById(any()); + + var messageDetailsDto = om.readValue(res.getResponse().getContentAsString(), IrisMessageDetailsDto.class); + + assertThat(messageDetailsDto.getHdRecipient().getId()).isEqualTo(messageInsert.getHdRecipient()); + assertThat(messageDetailsDto.getSubject()).isEqualTo(messageInsert.getSubject()); + assertThat(messageDetailsDto.getBody()).isEqualTo(messageInsert.getBody()); + + } + + @Test + @WithMockUser() + public void createMessage_shouldFail() throws Exception { + IrisMessage irisMessage = testData.MOCK_OUTBOX_MESSAGE; + mockMvc + .perform( + MockMvcRequestBuilders + .multipart(baseUrl) + .param("hdRecipient", irisMessage.getHdRecipient().getId()) + .param("subject", IrisMessageTestData.INVALID_SUBJECT) + .param("body", IrisMessageTestData.INVALID_BODY)) + .andExpect(MockMvcResultMatchers.status().is4xxClientError()) + .andReturn(); + + } + + @Test + @WithMockUser() + void getMessageDetails() throws Exception { + + IrisMessageIdentifier messageId = testData.MOCK_INBOX_MESSAGE.getId(); + + when(irisMessageService.findById(messageId)).thenReturn(Optional.of(testData.MOCK_INBOX_MESSAGE)); + + var res = mockMvc.perform(get(baseUrl + "/" + messageId)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andReturn(); + + verify(irisMessageService).findById(messageId); + + var messageDetailsDto = om.readValue(res.getResponse().getContentAsString(), IrisMessageDetailsDto.class); + + assertThat(messageDetailsDto) + .isEqualTo(IrisMessageDetailsDto.fromEntity(testData.MOCK_INBOX_MESSAGE)); + } + + @Test + @WithMockUser() + void getMessageDetails_shouldFail() throws Exception { + + IrisMessageIdentifier invalidId = IrisMessageIdentifier.of(UUID.randomUUID()); + + when(irisMessageService.findById(invalidId)).thenReturn(Optional.empty()); + + var res = mockMvc.perform(get(baseUrl + "/" + invalidId)) + .andExpect(MockMvcResultMatchers.status().is4xxClientError()) + .andReturn(); + + verify(irisMessageService).findById(invalidId); + + assertThat(res.getResponse().getContentAsString()).isEmpty(); + } + + @Test + @WithMockUser() + void updateMessage() throws Exception { + IrisMessageUpdateDto messageUpdate = new IrisMessageUpdateDto(true); + + IrisMessage updatedMessage = spy(testData.getTestInboxMessage()); + updatedMessage.setIsRead(true); + verify(updatedMessage).setIsRead(true); + + when(irisMessageService.findById(any())).thenReturn(Optional.of(updatedMessage)); + when(irisMessageService.saveMessage(updatedMessage)).thenReturn(updatedMessage); + + var res = mockMvc + .perform( + MockMvcRequestBuilders.patch(baseUrl + "/" + updatedMessage.getId()) + .content(om.writeValueAsString(messageUpdate)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andReturn(); + + verify(irisMessageService).findById(any()); + verify(irisMessageService).saveMessage(any(IrisMessage.class)); + + var messageDetailsDto = om.readValue(res.getResponse().getContentAsString(), IrisMessageDetailsDto.class); + + assertEquals(messageDetailsDto.getIsRead(), true); + assertThat(messageDetailsDto).isEqualTo(IrisMessageDetailsDto.fromEntity(updatedMessage)); + } + + @Test + @WithMockUser() + void updateMessage_shouldFail() throws Exception { + UUID invalidId = UUID.randomUUID(); + + IrisMessageUpdateDto messageUpdate = new IrisMessageUpdateDto(true); + + when(irisMessageService.findById(any())).thenReturn(Optional.empty()); + + mockMvc + .perform( + MockMvcRequestBuilders.patch(baseUrl + "/" + invalidId) + .content(om.writeValueAsString(messageUpdate)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().is4xxClientError()) + .andReturn(); + } + + @Test + @WithMockUser() + void getMessageFolders() throws Exception { + + List folderList = List.of(testData.MOCK_INBOX_FOLDER, testData.MOCK_OUTBOX_FOLDER); + + when(irisMessageService.getFolders()).thenReturn(folderList); + + var res = mockMvc.perform(get(baseUrl + "/folders")) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andReturn(); + + verify(irisMessageService).getFolders(); + + List folderDtoList = om.readValue(res.getResponse().getContentAsString(), + new TypeReference<>() {}); + + assertEquals(2, folderDtoList.size()); + assertThat(folderDtoList).isEqualTo(IrisMessageFolderDto.fromEntity(folderList)); + } + + @Test + @WithMockUser() + void downloadMessageFile() throws Exception { + when(irisMessageService.findFileById(any(IrisMessageFileIdentifier.class))).thenReturn(Optional.of(testData.MOCK_MESSAGE_FILE)); + + var res = mockMvc + .perform(MockMvcRequestBuilders.get(baseUrl + "/files/{id}/download", testData.MOCK_MESSAGE_FILE.getId())) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andReturn(); + + verify(irisMessageService).findFileById(any(IrisMessageFileIdentifier.class)); + + assertThat(res.getResponse().getHeader(HttpHeaders.CONTENT_DISPOSITION)).contains(testData.MOCK_MESSAGE_FILE.getName()); + assertThat(res.getResponse().getHeader(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS)).isEqualTo(HttpHeaders.CONTENT_DISPOSITION); + } + + @Test + @WithMockUser() + void getMessageHdContactsWithoutOwn() throws Exception { + + when(irisMessageService.getHdContacts(null)).thenReturn(List.of(testData.MOCK_CONTACT_OTHER)); + + var res = mockMvc + .perform(get(baseUrl + "/hd-contacts")) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andReturn(); + + verify(irisMessageService).getHdContacts(null); + + List contacts = om.readValue(res.getResponse().getContentAsString(), + new TypeReference<>() {}); + + assertEquals(1, contacts.size()); + assertThat(contacts).contains(testData.MOCK_CONTACT_OTHER); + assertThat(contacts).doesNotContain(testData.MOCK_CONTACT_OWN); + } + + @Test + @WithMockUser() + void getMessageHdContactsIncludingOwn() throws Exception { + + when(irisMessageService.getHdContacts(null)).thenReturn(List.of(testData.MOCK_CONTACT_OTHER)); + when(irisMessageService.getOwnHdContact()).thenReturn(testData.MOCK_CONTACT_OWN); + + var res = mockMvc + .perform(get(baseUrl + "/hd-contacts").queryParam("includeOwn", "true")) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andReturn(); + + verify(irisMessageService).getHdContacts(null); + verify(irisMessageService).getOwnHdContact(); + + List contacts = om.readValue(res.getResponse().getContentAsString(), + new TypeReference<>() {}); + + assertEquals(2, contacts.size()); + assertThat(contacts).contains(testData.MOCK_CONTACT_OTHER); + assertThat(contacts).contains(testData.MOCK_CONTACT_OWN); + } + + @Test + @WithMockUser() + void getUnreadMessageCount() throws Exception { + when(irisMessageService.getCountUnread()).thenReturn(2); + + var res = mockMvc + .perform(get(baseUrl + "/count/unread")) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andReturn(); + + verify(irisMessageService).getCountUnread(); + + Integer count = om.readValue(res.getResponse().getContentAsString(), new TypeReference<>() {}); + + assertEquals(2, count); + } + + @Test + @WithMockUser() + void getUnreadMessageCountByFolder() throws Exception { + when(irisMessageService.getCountUnreadByFolderId(any())).thenReturn(1); + + var res = mockMvc + .perform(get(baseUrl + "/count/unread") + .queryParam("folder", testData.MOCK_INBOX_FOLDER.getId().toString())) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andReturn(); + + verify(irisMessageService).getCountUnreadByFolderId(testData.MOCK_INBOX_FOLDER.getId()); + + Integer count = om.readValue(res.getResponse().getContentAsString(), new TypeReference<>() {}); + + assertEquals(1, count); + } + + private void getMessages(IrisMessage message, IrisMessageFolderIdentifier folderId) throws Exception { + + when(irisMessageService.search(eq(folderId), nullable(String.class), any(Pageable.class))) + .thenReturn(new RestResponsePage<>(List.of(message))); + + var res = mockMvc.perform(get(baseUrl) + .param("folder", folderId.toString())) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andReturn(); + + verify(irisMessageService).search(eq(folderId), nullable(String.class), any(Pageable.class)); + + var messagesPage = om.readValue(res.getResponse().getContentAsString(), PAGE_TYPE); + + assertEquals(1, messagesPage.getContent().size()); + assertThat(messagesPage.getContent().get(0)).isEqualTo(IrisMessageListItemDto.fromEntity(message)); + + } + +} diff --git a/iris-client-bff/src/test/resources/messages_de.properties b/iris-client-bff/src/test/resources/messages_de_DE_test.properties similarity index 100% rename from iris-client-bff/src/test/resources/messages_de.properties rename to iris-client-bff/src/test/resources/messages_de_DE_test.properties diff --git a/iris-client-fe/src/App.vue b/iris-client-fe/src/App.vue index 9233e9e53..b5a8fa622 100644 --- a/iris-client-fe/src/App.vue +++ b/iris-client-fe/src/App.vue @@ -3,26 +3,33 @@ - - {{ link.meta.menuName }} - + + + + {{ link.meta.menuName }} + + diff --git a/iris-client-fe/src/api/api.ts b/iris-client-fe/src/api/api.ts index 251fd79f3..f008b8d07 100644 --- a/iris-client-fe/src/api/api.ts +++ b/iris-client-fe/src/api/api.ts @@ -1,6 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { ApiResponse, assertParamExists, RequestOptions } from "./common"; +import { + ApiResponse, + assertParamExists, + DataQuery, + RequestOptions, +} from "./common"; import { BaseAPI } from "./base"; import { UserSession } from "@/views/user-login/user-login.store"; @@ -1883,6 +1888,87 @@ export interface CheckinAppStatusInfo { message: string; } +export interface Sort { + empty?: boolean; + sorted?: boolean; + unsorted?: boolean; +} + +export interface Page { + totalElements?: number; + totalPages?: number; + size?: number; + content: Content[]; + number?: number; + sort?: Sort; + first?: boolean; + last?: boolean; + numberOfElements?: number; + pageable?: Pageable; + empty?: boolean; +} + +export type IrisMessageQuery = DataQuery & { + folder?: string; +}; + +export type PageIrisMessages = Page; + +export interface Base64File { + name?: string; + content: string; +} + +export interface IrisMessageFileAttachment { + id: string; + name: string; + type?: string; +} + +export interface IrisMessage { + id: string; + folder: string; + context: IrisMessageContext; + subject: string; + body: string; + hdAuthor: IrisMessageHdContact; + hdRecipient: IrisMessageHdContact; + createdAt: string; + isRead?: boolean; + hasFileAttachments?: boolean; +} + +export interface IrisMessageDetails extends IrisMessage { + fileAttachments?: IrisMessageFileAttachment[]; +} + +export interface IrisMessageInsert { + hdRecipient: string; + subject: string; + body: string; + fileAttachments?: Base64File[]; +} + +export enum IrisMessageContext { + Inbox = "INBOX", + Outbox = "OUTBOX", + Unknown = "UNKNOWN", +} + +export type IrisMessageFolder = { + id: string; + name: string; + items?: IrisMessageFolder[]; + context?: IrisMessageContext; + isDefault?: boolean; +}; + +export interface IrisMessageHdContact { + id: string; + name: string; + isOwn?: boolean; +} + /** * IrisClientFrontendApi - object-oriented interface * @export @@ -2181,4 +2267,136 @@ export class IrisClientFrontendApi extends BaseAPI { const path = `/status/checkin-apps/${encodeURIComponent(name)}`; return this.apiRequest("GET", path, null, options); } + + /** + * @summary Fetches iris messages + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof IrisClientFrontendApi + */ + public irisMessagesGet( + options?: RequestOptions + ): ApiResponse { + assertParamExists("irisMessagesGet", "folder", options?.params?.folder); + return this.apiRequest("GET", "/iris-messages", null, options); + } + + /** + * @summary Fetches iris message details + * @param {string} messageId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof IrisClientFrontendApi + */ + public irisMessageDetailsGet( + messageId: string, + options?: RequestOptions + ): ApiResponse { + assertParamExists("irisMessageDetailsGet", "messageId", messageId); + const path = `/iris-messages/${encodeURIComponent(messageId)}`; + return this.apiRequest("GET", path, null, options); + } + + /** + * + * @summary Create IRIS message + * @param {IrisMessageInsert} data + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof IrisClientFrontendApi + */ + public irisMessagesPost( + data: IrisMessageInsert, + options?: RequestOptions + ): ApiResponse { + assertParamExists("irisMessagesPost", "data", data); + return this.apiRequest("POST", "/iris-messages", data, options); + } + + /** + * @summary Fetches allowed file types for iris message fileAttachments + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof IrisClientFrontendApi + */ + public irisMessageAllowedFileTypesGet( + options?: RequestOptions + ): ApiResponse { + return this.apiRequest( + "GET", + "/iris-messages/allowed-file-types", + null, + options + ); + } + + /** + * @summary Fetches iris message folders + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof IrisClientFrontendApi + */ + public irisMessageFoldersGet( + options?: RequestOptions + ): ApiResponse { + return this.apiRequest("GET", "/iris-messages/folders", null, options); + } + + /** + * @summary Fetches iris message contacts + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof IrisClientFrontendApi + */ + public irisMessageHdContactsGet( + options?: RequestOptions + ): ApiResponse { + return this.apiRequest("GET", "/iris-messages/hd-contacts", null, options); + } + + /** + * @summary Fetches number of unread messages + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof IrisClientFrontendApi + */ + public irisUnreadMessageCountGet( + options?: RequestOptions + ): ApiResponse { + return this.apiRequest("GET", "/iris-messages/count/unread", null, options); + } + + /** + * + * @summary Mark IRIS message as read + * @param {string} messageId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof IrisClientFrontendApi + */ + public irisMessagesSetIsRead( + messageId: string, + options?: RequestOptions + ): ApiResponse { + assertParamExists("irisMessagesSetIsRead", "messageId", messageId); + const path = `/iris-messages/${encodeURIComponent(messageId)}`; + return this.apiRequest("PATCH", path, { isRead: true }, options); + } + + /** + * @summary Download file + * @param {string} fileId + * @param {*} options Override http request option. + */ + public irisMessageFileDownload( + fileId: string, + options?: RequestOptions + ): ApiResponse { + assertParamExists("irisMessageFileDownload", "fileId", fileId); + const path = `/iris-messages/files/${encodeURIComponent(fileId)}/download`; + return this.apiRequest("GET", path, null, { + ...options, + responseType: "blob", + }); + } } diff --git a/iris-client-fe/src/api/common.ts b/iris-client-fe/src/api/common.ts index 88c9dea6d..7745436df 100644 --- a/iris-client-fe/src/api/common.ts +++ b/iris-client-fe/src/api/common.ts @@ -5,9 +5,11 @@ import globalAxios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, + CancelToken, + CancelTokenSource, Method, } from "axios"; - +import axios from "axios"; /** * * @export @@ -128,3 +130,16 @@ export const apiRequestBuilder = ...options, }); }; + +export const cancelTokenProvider = () => { + let source: CancelTokenSource = axios.CancelToken.source(); + return (): CancelToken => { + try { + source.cancel("request canceled"); + source = axios.CancelToken.source(); + } catch (e) { + // ignored + } + return source.token; + }; +}; diff --git a/iris-client-fe/src/assets/logo-iris-connect.png b/iris-client-fe/src/assets/logo-iris-connect.png index 4a4ef169f..f5d6e370e 100644 Binary files a/iris-client-fe/src/assets/logo-iris-connect.png and b/iris-client-fe/src/assets/logo-iris-connect.png differ diff --git a/iris-client-fe/src/assets/scss/base/_global.scss b/iris-client-fe/src/assets/scss/base/_global.scss index 87022c6e5..9e77551ac 100644 --- a/iris-client-fe/src/assets/scss/base/_global.scss +++ b/iris-client-fe/src/assets/scss/base/_global.scss @@ -40,3 +40,7 @@ a:hover { left: 20%; right: 20%; } + +.cursor-pointer { + cursor: pointer; +} \ No newline at end of file diff --git a/iris-client-fe/src/components/data-tree/data-tree.vue b/iris-client-fe/src/components/data-tree/data-tree.vue new file mode 100644 index 000000000..1fce10a5f --- /dev/null +++ b/iris-client-fe/src/components/data-tree/data-tree.vue @@ -0,0 +1,86 @@ + + + + + {{ showItems ? "mdi-folder-open" : "mdi-folder" }} + + + {{ item.name }} + + + + + + + + + diff --git a/iris-client-fe/src/components/form/file-input-field.vue b/iris-client-fe/src/components/form/file-input-field.vue new file mode 100644 index 000000000..5ab4861ac --- /dev/null +++ b/iris-client-fe/src/components/form/file-input-field.vue @@ -0,0 +1,69 @@ + + + + + {{ text }} + + + + + + diff --git a/iris-client-fe/src/components/pageable/search-field.vue b/iris-client-fe/src/components/pageable/search-field.vue new file mode 100644 index 000000000..e5bc75616 --- /dev/null +++ b/iris-client-fe/src/components/pageable/search-field.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/iris-client-fe/src/components/sortable-data-table.vue b/iris-client-fe/src/components/sortable-data-table.vue new file mode 100644 index 000000000..e85c78733 --- /dev/null +++ b/iris-client-fe/src/components/sortable-data-table.vue @@ -0,0 +1,96 @@ + + + + + + + + + diff --git a/iris-client-fe/src/router/index.ts b/iris-client-fe/src/router/index.ts index f262efd28..6e35e9373 100644 --- a/iris-client-fe/src/router/index.ts +++ b/iris-client-fe/src/router/index.ts @@ -167,6 +167,44 @@ export const routes: Array = [ /* webpackChunkName: "index-tracking-details" */ "../views/index-tracking-details/index-tracking-details.view.vue" ), }, + { + path: "/iris-messages/list", + name: "iris-message-list" /* Caution: This acts as an identifier! */, + meta: { + menu: true, + menuName: "Nachrichten", + menuComponent: () => + import( + /* webpackChunkName: "iris-message-list-nav-link" */ "../views/iris-message-list/components/iris-message-list-nav-link.vue" + ), + }, + component: () => + import( + /* webpackChunkName: "iris-message-list" */ "../views/iris-message-list/iris-message-list.view.vue" + ), + }, + { + path: "/iris-messages/details/:messageId", + name: "iris-message-details" /* Caution: This acts as an identifier! */, + meta: { + menu: false, + }, + component: () => + import( + /* webpackChunkName: "iris-message-details" */ "../views/iris-message-details/iris-message-details.view.vue" + ), + }, + { + path: "/iris-messages/create", + name: "iris-message-create" /* Caution: This acts as an identifier! */, + meta: { + menu: false, + }, + component: () => + import( + /* webpackChunkName: "iris-message-create" */ "../views/iris-message-create/iris-message-create.view.vue" + ), + }, { path: "/about", name: "about" /* Caution: This acts as an identifier! */, @@ -185,7 +223,7 @@ const router = new VueRouter({ routes, }); -const locationFromRoute = (route: Route): Location => { +export const locationFromRoute = (route: Route): Location => { const { name, path, hash, query, params } = route; return { ...(name ? { name } : {}), diff --git a/iris-client-fe/src/server/data/dummy-iris-messages.ts b/iris-client-fe/src/server/data/dummy-iris-messages.ts new file mode 100644 index 000000000..0c87d5d01 --- /dev/null +++ b/iris-client-fe/src/server/data/dummy-iris-messages.ts @@ -0,0 +1,175 @@ +import { daysAgo } from "@/server/utils/date"; +import { + IrisMessageHdContact, + IrisMessageContext, + IrisMessageDetails, + IrisMessageFolder, + IrisMessageInsert, + IrisMessageFileAttachment, +} from "@/api"; +import { Request } from "miragejs"; + +export const dummyIrisMessageFolders: IrisMessageFolder[] = [ + { + id: "inbox", + name: "Posteingang", + context: IrisMessageContext.Inbox, + isDefault: true, + items: [ + { + id: "inbox_1", + name: "Ordner 1", + context: IrisMessageContext.Inbox, + }, + { + id: "inbox_2", + name: "Ordner 2", + context: IrisMessageContext.Inbox, + items: [ + { + id: "inbox_2_1", + name: "Ordner 2 1", + context: IrisMessageContext.Inbox, + }, + { + id: "inbox_2_2", + name: "Ordner 2 2", + context: IrisMessageContext.Inbox, + }, + ], + }, + ], + }, + { + id: "outbox", + name: "Postausgang", + context: IrisMessageContext.Outbox, + }, +]; + +export const dummyIrisMessageHdContacts: IrisMessageHdContact[] = [ + { + id: "1", + name: "Eigenes GA", + isOwn: true, + }, + { + id: "2", + name: "Kontakt 2", + }, + { + id: "3", + name: "Kontakt 3", + }, + { + id: "4", + name: "Kontakt 4", + }, + { + id: "5", + name: "Kontakt 5", + }, +]; + +export const dummyIrisMessageFileAttachments: IrisMessageFileAttachment[] = [ + { + id: "file_1", + name: "Anhang 1", + type: "pdf", + }, + { + id: "file_2", + name: "Liste 2", + type: "csv", + }, +]; + +export const dummyIrisMessageList: IrisMessageDetails[] = [ + { + hdAuthor: dummyIrisMessageHdContacts[1], + hdRecipient: dummyIrisMessageHdContacts[0], + folder: "inbox", + context: IrisMessageContext.Inbox, + id: "m1", + subject: "Indexfall-Anfrage consetetur sadipscing elitr", + body: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.", + createdAt: daysAgo(3), + isRead: false, + fileAttachments: dummyIrisMessageFileAttachments, + }, + { + hdAuthor: dummyIrisMessageHdContacts[0], + hdRecipient: dummyIrisMessageHdContacts[4], + folder: "outbox", + context: IrisMessageContext.Outbox, + id: "2", + subject: "Austausch", + body: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.", + createdAt: daysAgo(1), + isRead: true, + }, + { + hdAuthor: dummyIrisMessageHdContacts[2], + hdRecipient: dummyIrisMessageHdContacts[0], + folder: "inbox_2_1", + context: IrisMessageContext.Inbox, + id: "5", + subject: "Anfrage", + body: "At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.", + createdAt: daysAgo(5), + isRead: false, + fileAttachments: dummyIrisMessageFileAttachments, + }, + { + hdAuthor: dummyIrisMessageHdContacts[0], + hdRecipient: dummyIrisMessageHdContacts[2], + context: IrisMessageContext.Outbox, + folder: "outbox", + id: "asdf", + subject: "Lorem ipsum gubergren, no sea takimata ", + body: "Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.", + createdAt: daysAgo(2), + isRead: true, + }, + { + hdAuthor: dummyIrisMessageHdContacts[3], + hdRecipient: dummyIrisMessageHdContacts[0], + context: IrisMessageContext.Inbox, + folder: "inbox", + id: "271", + subject: "Test", + body: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et", + createdAt: daysAgo(4), + isRead: true, + }, +]; + +export const getDummyMessageFromRequest = ( + request: Request, + id?: string +): IrisMessageDetails => { + const form: IrisMessageInsert = JSON.parse(request.requestBody); + const subject = form.subject; + const body = form.body; + const recipient = form.hdRecipient; + return { + id: id || new Date().getTime() + "", + subject, + body, + folder: "outbox", + isRead: true, + context: IrisMessageContext.Outbox, + hdAuthor: dummyIrisMessageHdContacts[0], + hdRecipient: + dummyIrisMessageHdContacts.find((c) => c.id === recipient) || + dummyIrisMessageHdContacts[1], + createdAt: new Date().getTime() + "", + fileAttachments: form.fileAttachments?.map((fileAttachment) => { + return { + id: new Date().getTime() + "", + name: fileAttachment.name + "", + type: "", + }; + }), + }; +}; diff --git a/iris-client-fe/src/server/mockAPIServer.ts b/iris-client-fe/src/server/mockAPIServer.ts index a017cd577..8c121f023 100644 --- a/iris-client-fe/src/server/mockAPIServer.ts +++ b/iris-client-fe/src/server/mockAPIServer.ts @@ -5,6 +5,8 @@ import { DataRequestDetails, DataRequestStatus, ExistingDataRequestClientWithLocation, + IrisMessage, + IrisMessageQuery, User, UserRole, } from "@/api"; @@ -25,13 +27,20 @@ import { getDummyUserFromRequest, } from "@/server/data/dummy-userlist"; import { findIndex, remove, some } from "lodash"; -import { paginated } from "@/server/utils/pagination"; +import { paginated, queriedPage } from "@/server/utils/pagination"; import dayjs from "@/utils/date"; import _defaults from "lodash/defaults"; import { dummyCheckinApps, getDummyCheckinAppStatus, } from "@/server/data/status-checkin-apps"; +import { + dummyIrisMessageFolders, + dummyIrisMessageList, + dummyIrisMessageHdContacts, + getDummyMessageFromRequest, + dummyIrisMessageFileAttachments, +} from "@/server/data/dummy-iris-messages"; const loginResponse = (role: UserRole): Response => { return new Response(200, { @@ -43,14 +52,15 @@ const loginResponse = (role: UserRole): Response => { const authResponse = ( request?: Request, // eslint-disable-next-line @typescript-eslint/ban-types - data?: string | {} | undefined + data?: string | {} | undefined, + headers?: Record ): Response => { if (request) { if (!validateAuthHeader(request)) { - return new Response(401, { error: "not authorized" }); + return new Response(401, { error: "not authorized", ...headers }); } } - return new Response(200, undefined, data); + return new Response(200, headers, data); }; const validateAuthHeader = (request: Request): boolean => { @@ -291,6 +301,74 @@ export function makeMockAPIServer() { getDummyCheckinAppStatus(request.params.name) ); }); + + this.get("/iris-messages", (schema, request) => { + const query: Partial = request.queryParams; + return authResponse( + request, + queriedPage(dummyIrisMessageList as IrisMessage[], query) + ); + }); + + this.get("/iris-messages/:messageId", (schema, request) => { + const message = dummyIrisMessageList.find( + (msg) => msg.id === request.params.messageId + ); + return authResponse(request, message); + }); + + this.patch("/iris-messages/:messageId", (schema, request) => { + const message = dummyIrisMessageList.find((msg) => { + if (msg.id === request.params.messageId) { + msg.isRead = true; + return true; + } + return false; + }); + return authResponse(request, message); + }); + + this.post("/iris-messages", (schema, request) => { + try { + if (validateAuthHeader(request)) { + dummyIrisMessageList.push(getDummyMessageFromRequest(request)); + } + } catch (e) { + // ignored + } + return authResponse(request); + }); + + this.get("/iris-messages/folders", (schema, request) => { + return authResponse(request, dummyIrisMessageFolders); + }); + + this.get("/iris-messages/hd-contacts", (schema, request) => { + return authResponse(request, dummyIrisMessageHdContacts); + }); + + this.get("/iris-messages/count/unread", (schema, request) => { + return authResponse( + request, + dummyIrisMessageList.filter((item) => !item.isRead).length + ); + }); + + this.get("/iris-messages/allowed-file-types", (schema, request) => { + return authResponse(request, ["image/*"]); + }); + + this.get("/iris-messages/files/:fileId/download", (schema, request) => { + let fileAttachment = dummyIrisMessageFileAttachments.find( + (item) => item.id === request.params.fileId + ); + if (!fileAttachment) { + fileAttachment = dummyIrisMessageFileAttachments[0]; + } + return authResponse(request, "dummy file content", { + "content-disposition": `filename="${fileAttachment.name}.txt"`, + }); + }); }, }); diff --git a/iris-client-fe/src/server/utils/pagination.ts b/iris-client-fe/src/server/utils/pagination.ts index df04beedc..e2b3cbefe 100644 --- a/iris-client-fe/src/server/utils/pagination.ts +++ b/iris-client-fe/src/server/utils/pagination.ts @@ -1,4 +1,17 @@ -import { PageEvent, PageIndexCase } from "@/api"; +import { Page, PageEvent, PageIndexCase } from "@/api"; +import { DataQuery } from "@/api/common"; +import { DEFAULT_PAGE_SIZE } from "@/utils/pagination"; +import _orderBy from "lodash/orderBy"; +import _get from "lodash/get"; + +export enum TableSortDirection { + ASC = "asc", + DESC = "desc", +} +export type TableSort = { + col: string; + dir: TableSortDirection; +}; type Named = { name?: string; @@ -19,3 +32,36 @@ export function paginated( }), }; } + +export const queriedPage = >( + items: T[], + query: Q +): Page => { + // @todo: add search functionality if possible + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { page, size, sort, search, ...filters } = query; + const qPage = Number(page || 0); + const qSize = Number(size || DEFAULT_PAGE_SIZE); + const qSort = (sort || "").split(","); + const sortedItems = + qSort.length > 1 + ? _orderBy(items, [qSort[0]], [qSort[1] as TableSortDirection]) + : items; + const filteredItems = sortedItems.filter((item) => { + return Object.keys(filters).find((fKey) => { + const iValue = _get(item, fKey); + const fValue = _get(filters, fKey); + if (fValue && iValue) { + return _get(item, fKey) === _get(filters, fKey); + } + return true; + }); + }); + return { + totalElements: filteredItems.length, + totalPages: Math.ceil(filteredItems.length / qSize), + size: qSize, + number: qPage, + content: filteredItems.slice(qPage * qSize, qPage * qSize + qSize), + }; +}; diff --git a/iris-client-fe/src/store/store.config.ts b/iris-client-fe/src/store/store.config.ts index e1c52e789..d39371f37 100644 --- a/iris-client-fe/src/store/store.config.ts +++ b/iris-client-fe/src/store/store.config.ts @@ -13,13 +13,16 @@ import indexTrackingDetails from "@/views/index-tracking-details/index-tracking- import indexTrackingSettings from "@/views/app-settings/index-tracking-settings.store"; import normalizeSettings from "@/views/app-settings/normalize-settings.store"; import chunkLoader from "@/views/app-settings/chunk-loader.store"; +import checkinAppStatusList from "@/views/checkin-app-status-list/checkin-app-status-list.store"; +import irisMessageList from "@/views/iris-message-list/iris-message-list.store"; +import irisMessageDetails from "@/views/iris-message-details/iris-message-details.store"; +import irisMessageCreate from "@/views/iris-message-create/iris-message-create.store"; import { StoreOptions } from "vuex"; import { RootState } from "@/store/types"; import home from "@/views/home/home.store"; import createPersistedState from "vuex-persistedstate"; -import checkinAppStatusList from "@/views/checkin-app-status-list/checkin-app-status-list.store"; export const storeOptions: StoreOptions = { state: {} as RootState, @@ -42,6 +45,9 @@ export const storeOptions: StoreOptions = { normalizeSettings, chunkLoader, checkinAppStatusList, + irisMessageList, + irisMessageDetails, + irisMessageCreate, }, plugins: [ createPersistedState({ diff --git a/iris-client-fe/src/store/types.ts b/iris-client-fe/src/store/types.ts index f97f0aae6..3130b4cdf 100644 --- a/iris-client-fe/src/store/types.ts +++ b/iris-client-fe/src/store/types.ts @@ -14,6 +14,9 @@ import { IndexTrackingSettingsState } from "@/views/app-settings/index-tracking- import { NormalizeSettingsState } from "@/views/app-settings/normalize-settings.store"; import { ChunkLoaderState } from "@/views/app-settings/chunk-loader.store"; import { CheckinAppStatusListState } from "@/views/checkin-app-status-list/checkin-app-status-list.store"; +import { IrisMessageListState } from "@/views/iris-message-list/iris-message-list.store"; +import { IrisMessageDetailsState } from "@/views/iris-message-details/iris-message-details.store"; +import { IrisMessageCreateState } from "@/views/iris-message-create/iris-message-create.store"; export type RootState = { home: HomeState; @@ -32,4 +35,7 @@ export type RootState = { normalizeSettings: NormalizeSettingsState; chunkLoader: ChunkLoaderState; checkinAppStatusList: CheckinAppStatusListState; + irisMessageList: IrisMessageListState; + irisMessageDetails: IrisMessageDetailsState; + irisMessageCreate: IrisMessageCreateState; }; diff --git a/iris-client-fe/src/utils/data.ts b/iris-client-fe/src/utils/data.ts index 6463dcac0..22f7d68e2 100644 --- a/iris-client-fe/src/utils/data.ts +++ b/iris-client-fe/src/utils/data.ts @@ -17,6 +17,25 @@ export const normalizeData = ( return finalizeData(callback(normalizer), source, parse, message); }; +export const normalizeValue = ( + source: T | undefined, + callback: (n: ValueNormalizer) => T, + parse?: boolean, + message?: string +): T => { + if (!isEnabled()) return source as T; + const normalizer = valueNormalizer(source); + return finalizeData(callback(normalizer), source, parse, message); +}; + +export type ValueNormalizer = (fallback: T, type?: string) => T; + +export const valueNormalizer = + (value?: T) => + (fallback: T, type = "string"): T => { + return getNormalizedValue(value, fallback, type); + }; + // utility type to check if all keys of T exist export type Complete = { [P in keyof Required]: Pick extends Required> @@ -34,20 +53,28 @@ export type EntryNormalizer = ( type?: string ) => T[K]; -export const entryNormalizer = +const entryNormalizer = (obj?: T) => (key: K, fallback: T[K], type = "string"): T[K] => { - return normalize(obj, key, type, fallback); + return normalize(obj, key, fallback, type); }; -export const normalize = ( +const normalize = ( obj: T | unknown | undefined, key: K, - type: string, - fallback: T[K] + fallback: T[K], + type = "string" ): T[K] => { - const val: T[K] = _get(obj, key); - if (val !== undefined && validateType(val, type)) return val; + const value: T[K] = _get(obj, key); + return getNormalizedValue(value, fallback, type); +}; + +export const getNormalizedValue = ( + value: T | undefined, + fallback: T, + type = "string" +): T => { + if (value !== undefined && validateType(value, type)) return value; return fallback; }; @@ -64,7 +91,7 @@ const validateType = (value: unknown, type: string): boolean => { return typeof value === type; }; -export const parseData = (data: T): T => { +const parseData = (data: T): T => { try { return JSON.parse(JSON.stringify(data)); } catch { @@ -72,7 +99,7 @@ export const parseData = (data: T): T => { } }; -export const finalizeData = ( +const finalizeData = ( normalized: A, source?: A, parse?: boolean, @@ -84,7 +111,7 @@ export const finalizeData = ( return parsed; }; -export const notifyDifference = (a: A, b: A, msg?: string): void => { +const notifyDifference = (a: A, b: A, msg?: string): void => { if (store.state.normalizeSettings.logEnabled) { if (a && b) { const diffA = difference(a, b); @@ -107,7 +134,7 @@ export const notifyDifference = (a: A, b: A, msg?: string): void => { }; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const difference = , B extends A>( +const difference = , B extends A>( object: A, base: B ): Record => { diff --git a/iris-client-fe/src/utils/fileUtil.ts b/iris-client-fe/src/utils/fileUtil.ts new file mode 100644 index 000000000..61257d030 --- /dev/null +++ b/iris-client-fe/src/utils/fileUtil.ts @@ -0,0 +1,73 @@ +import _isArrayBuffer from "lodash/isArrayBuffer"; +import _isTypedArray from "lodash/isTypedArray"; + +type LegacyNavigator = Navigator & { + msSaveBlob?: (blob: Blob, defaultName?: string) => boolean; +}; + +const fileToBase64 = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + if (typeof reader.result === "string") { + const dataUrl = reader.result; + resolve(dataUrl.split(",")[1] || ""); + } else { + reject("invalid result"); + } + }; + reader.onerror = (error) => reject(error); + }); + +const base64ToFile = (base64: string, fileName: string) => { + const bStr = atob(base64); + let n = bStr.length; + const u8arr = new Uint8Array(n); + while (n--) { + u8arr[n] = bStr.charCodeAt(n); + } + return new File([u8arr], fileName); +}; + +const validateData = (data: BlobPart): boolean => { + if (typeof data === "string") return true; + if (data instanceof Blob) return true; + if (_isArrayBuffer(data)) return true; + if (_isTypedArray(data)) return true; + return data instanceof DataView; +}; + +const download = (data: BlobPart, fileName: string) => { + if (!fileUtil.validateData(data)) { + throw new Error("invalid file data"); + } + const legacyNavigator = navigator as LegacyNavigator; + if (legacyNavigator.msSaveBlob) { + // IE10 + const blob = + data instanceof Blob + ? data + : new Blob([data], { type: "application/octet-stream" }); + legacyNavigator.msSaveBlob(blob, fileName); + } else { + const blob = data instanceof Blob ? data : new Blob([data]); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = downloadUrl; + link.setAttribute("download", fileName); + link.target = "_blank"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } +}; + +const fileUtil = { + fileToBase64, + base64ToFile, + validateData, + download, +}; + +export default fileUtil; diff --git a/iris-client-fe/src/utils/pagination.ts b/iris-client-fe/src/utils/pagination.ts index 885b9cf2a..dcda79868 100644 --- a/iris-client-fe/src/utils/pagination.ts +++ b/iris-client-fe/src/utils/pagination.ts @@ -1,10 +1,10 @@ import { Route } from "vue-router"; import { DataRequestStatus } from "@/api"; -const DEFAULT_PAGE_SIZE = 20; +export const DEFAULT_PAGE_SIZE = 20; export function getStringParamFromRouteWithOptionalFallback( - param: "page" | "sort" | "search" | "status" | "size", + param: "page" | "sort" | "search" | "status" | "size" | "folder", route: Route, fallback?: string ): string | undefined { diff --git a/iris-client-fe/src/views/checkin-app-status-list/components/checkin-app-status-indicator.vue b/iris-client-fe/src/views/checkin-app-status-list/components/checkin-app-status-indicator.vue index 1a9ddd632..63e7146ca 100644 --- a/iris-client-fe/src/views/checkin-app-status-list/components/checkin-app-status-indicator.vue +++ b/iris-client-fe/src/views/checkin-app-status-list/components/checkin-app-status-indicator.vue @@ -1,11 +1,11 @@ - + - + mdi-alert-octagon diff --git a/iris-client-fe/src/views/iris-message-create/iris-message-create.data.ts b/iris-client-fe/src/views/iris-message-create/iris-message-create.data.ts new file mode 100644 index 000000000..0ed0f4318 --- /dev/null +++ b/iris-client-fe/src/views/iris-message-create/iris-message-create.data.ts @@ -0,0 +1,17 @@ +import { IrisMessageHdContact } from "@/api"; +import { normalizeData } from "@/utils/data"; +import { normalizeIrisMessageHdContact } from "@/views/iris-message-list/iris-message-list.data"; + +export const normalizeIrisMessageHdContacts = ( + source?: IrisMessageHdContact[], + parse?: boolean +): IrisMessageHdContact[] => { + return normalizeData( + source, + () => { + return (source || []).map((item) => normalizeIrisMessageHdContact(item)); + }, + parse, + "IrisMessageHdContacts" + ); +}; diff --git a/iris-client-fe/src/views/iris-message-create/iris-message-create.store.ts b/iris-client-fe/src/views/iris-message-create/iris-message-create.store.ts new file mode 100644 index 000000000..7e036703f --- /dev/null +++ b/iris-client-fe/src/views/iris-message-create/iris-message-create.store.ts @@ -0,0 +1,143 @@ +import { RootState } from "@/store/types"; + +import { Commit, Module } from "vuex"; +import authClient from "@/api-client"; +import { ErrorMessage, getErrorMessage } from "@/utils/axios"; +import { IrisMessageInsert, IrisMessageHdContact } from "@/api"; +import { normalizeIrisMessageHdContacts } from "@/views/iris-message-create/iris-message-create.data"; +import { cancelTokenProvider } from "@/api/common"; + +export type IrisMessageCreateState = { + messageCreationOngoing: boolean; + messageCreationError: ErrorMessage; + contacts: IrisMessageHdContact[] | null; + contactsLoading: boolean; + contactsLoadingError: ErrorMessage; + allowedFileTypes: string[] | null; +}; + +export interface IrisMessageDetailsModule + extends Module { + mutations: { + setMessageCreationOngoing( + state: IrisMessageCreateState, + payload: boolean + ): void; + setMessageCreationError( + state: IrisMessageCreateState, + payload: ErrorMessage + ): void; + setContacts( + state: IrisMessageCreateState, + payload: IrisMessageHdContact[] + ): void; + setContactsLoading(state: IrisMessageCreateState, payload: boolean): void; + setContactsLoadingError( + state: IrisMessageCreateState, + payload: ErrorMessage + ): void; + setAllowedFileTypes( + state: IrisMessageCreateState, + payload: string[] | null + ): void; + reset(state: IrisMessageCreateState, payload: null): void; + }; + actions: { + createMessage( + { commit }: { commit: Commit }, + data: IrisMessageInsert + ): Promise; + fetchRecipients( + { commit }: { commit: Commit }, + search?: string + ): Promise; + fetchAllowedFileTypes({ commit }: { commit: Commit }): Promise; + }; +} + +const defaultState: IrisMessageCreateState = { + messageCreationOngoing: false, + messageCreationError: null, + contacts: null, + contactsLoading: false, + contactsLoadingError: null, + allowedFileTypes: null, +}; + +const cancel_fetchRecipients = cancelTokenProvider(); + +const irisMessageCreate: IrisMessageDetailsModule = { + namespaced: true, + state() { + return { ...defaultState }; + }, + mutations: { + setMessageCreationOngoing(state, payload) { + state.messageCreationOngoing = payload; + }, + setMessageCreationError(state, payload) { + state.messageCreationError = payload; + }, + setContacts(state, payload) { + state.contacts = payload; + }, + setContactsLoading(state, payload) { + state.contactsLoading = payload; + }, + setContactsLoadingError(state, payload) { + state.contactsLoadingError = payload; + }, + setAllowedFileTypes(state, payload) { + state.allowedFileTypes = payload; + }, + reset(state) { + Object.assign(state, { ...defaultState }); + }, + }, + actions: { + async createMessage({ commit }, data) { + commit("setMessageCreationError", null); + commit("setMessageCreationOngoing", true); + try { + await authClient.irisMessagesPost(data); + } catch (e) { + commit("setMessageCreationError", getErrorMessage(e)); + throw e; + } finally { + commit("setMessageCreationOngoing", false); + } + }, + async fetchRecipients({ commit }, search) { + let list: IrisMessageHdContact[] | null = null; + commit("setContactsLoading", true); + commit("setContactsLoadingError", null); + try { + const requestOptions = { + cancelToken: cancel_fetchRecipients(), + params: { includeOwn: !!window.Cypress, search }, + }; + list = normalizeIrisMessageHdContacts( + (await authClient.irisMessageHdContactsGet(requestOptions)).data, + true + ); + } catch (e) { + commit("setContactsLoadingError", getErrorMessage(e)); + } finally { + commit("setContacts", list); + commit("setContactsLoading", false); + } + }, + async fetchAllowedFileTypes({ commit }) { + let fileTypes: string[] | null = null; + try { + fileTypes = (await authClient.irisMessageAllowedFileTypesGet()).data; + } catch (e) { + // ignored + } finally { + commit("setAllowedFileTypes", fileTypes); + } + }, + }, +}; + +export default irisMessageCreate; diff --git a/iris-client-fe/src/views/iris-message-create/iris-message-create.view.vue b/iris-client-fe/src/views/iris-message-create/iris-message-create.view.vue new file mode 100644 index 000000000..2062bf911 --- /dev/null +++ b/iris-client-fe/src/views/iris-message-create/iris-message-create.view.vue @@ -0,0 +1,169 @@ + + + + + Nachricht schreiben + + + + + + + + + Abbrechen + + + + Senden + + + + + + + + + diff --git a/iris-client-fe/src/views/iris-message-details/iris-message-details.data.ts b/iris-client-fe/src/views/iris-message-details/iris-message-details.data.ts new file mode 100644 index 000000000..d9908989c --- /dev/null +++ b/iris-client-fe/src/views/iris-message-details/iris-message-details.data.ts @@ -0,0 +1,44 @@ +import { IrisMessageFileAttachment, IrisMessageDetails } from "@/api"; +import { Complete, normalizeData } from "@/utils/data"; +import { normalizeIrisMessage } from "@/views/iris-message-list/iris-message-list.data"; + +const normalizeIrisMessageFileAttachment = ( + source?: IrisMessageFileAttachment, + parse?: boolean +): IrisMessageFileAttachment => { + return normalizeData( + source, + (normalizer) => { + const normalized: Complete = { + id: normalizer("id", ""), + name: normalizer("name", ""), + type: normalizer("type", undefined), + }; + return normalized; + }, + parse, + "IrisMessageFileAttachment" + ); +}; + +export const normalizeIrisMessageDetails = ( + source?: IrisMessageDetails, + parse?: boolean +): IrisMessageDetails => { + return normalizeData( + source, + () => { + const normalized: IrisMessageDetails = { + ...normalizeIrisMessage(source), + fileAttachments: source?.fileAttachments + ? source.fileAttachments.map((item) => + normalizeIrisMessageFileAttachment(item) + ) + : undefined, + }; + return normalized; + }, + parse, + "IrisMessageDetails" + ); +}; diff --git a/iris-client-fe/src/views/iris-message-details/iris-message-details.store.ts b/iris-client-fe/src/views/iris-message-details/iris-message-details.store.ts new file mode 100644 index 000000000..d5d38ab1d --- /dev/null +++ b/iris-client-fe/src/views/iris-message-details/iris-message-details.store.ts @@ -0,0 +1,162 @@ +import { RootState } from "@/store/types"; + +import { Commit, Module } from "vuex"; +import { IrisMessageDetails } from "@/api"; +import authClient from "@/api-client"; +import { ErrorMessage, getErrorMessage } from "@/utils/axios"; +import { normalizeIrisMessageDetails } from "@/views/iris-message-details/iris-message-details.data"; +import fileUtil from "@/utils/fileUtil"; +import { AxiosResponse } from "axios"; + +export type IrisMessageDetailsState = { + message: IrisMessageDetails | null; + messageLoading: boolean; + messageLoadingError: ErrorMessage; + messageSaving: boolean; + messageSavingError: ErrorMessage; + fileAttachmentLoading: boolean; + fileAttachmentLoadingError: ErrorMessage; +}; + +export interface IrisMessageDetailsModule + extends Module { + mutations: { + setMessage( + state: IrisMessageDetailsState, + payload: IrisMessageDetails + ): void; + setMessageLoading(state: IrisMessageDetailsState, payload: boolean): void; + setMessageLoadingError( + state: IrisMessageDetailsState, + payload: ErrorMessage + ): void; + setMessageSaving(state: IrisMessageDetailsState, payload: boolean): void; + setMessageSavingError( + state: IrisMessageDetailsState, + payload: ErrorMessage + ): void; + setFileAttachmentLoading( + state: IrisMessageDetailsState, + payload: boolean + ): void; + setFileAttachmentLoadingError( + state: IrisMessageDetailsState, + payload: ErrorMessage + ): void; + reset(state: IrisMessageDetailsState, payload: null): void; + }; + actions: { + fetchMessage( + { commit }: { commit: Commit }, + messageId: string + ): Promise; + markAsRead( + { commit }: { commit: Commit }, + messageId: string + ): Promise; + downloadFileAttachment( + { commit }: { commit: Commit }, + fileId: string + ): Promise; + }; +} + +const defaultState: IrisMessageDetailsState = { + message: null, + messageLoading: false, + messageLoadingError: null, + messageSaving: false, + messageSavingError: null, + fileAttachmentLoading: false, + fileAttachmentLoadingError: null, +}; + +const irisMessageDetails: IrisMessageDetailsModule = { + namespaced: true, + state() { + return { ...defaultState }; + }, + mutations: { + setMessage(state, payload) { + state.message = payload; + }, + setMessageLoading(state, payload) { + state.messageLoading = payload; + }, + setMessageLoadingError(state, payload) { + state.messageLoadingError = payload; + }, + setMessageSaving(state, payload) { + state.messageSaving = payload; + }, + setMessageSavingError(state, payload) { + state.messageSavingError = payload; + }, + setFileAttachmentLoading(state, payload) { + state.fileAttachmentLoading = payload; + }, + setFileAttachmentLoadingError(state, payload) { + state.fileAttachmentLoadingError = payload; + }, + reset(state) { + Object.assign(state, { ...defaultState }); + }, + }, + actions: { + async fetchMessage({ commit }, messageId) { + let data: IrisMessageDetails | null = null; + commit("setMessageLoading", true); + commit("setMessageLoadingError", null); + try { + data = normalizeIrisMessageDetails( + (await authClient.irisMessageDetailsGet(messageId)).data, + true + ); + } catch (e) { + commit("setMessageLoadingError", getErrorMessage(e)); + } finally { + commit("setMessage", data); + commit("setMessageLoading", false); + } + }, + async markAsRead({ commit }, messageId) { + commit("setMessageSaving", true); + commit("setMessageSavingError", null); + try { + const data: IrisMessageDetails = normalizeIrisMessageDetails( + (await authClient.irisMessagesSetIsRead(messageId)).data, + true + ); + commit("setMessage", data); + } catch (e) { + commit("setMessageLoadingError", getErrorMessage(e)); + } finally { + commit("setMessageSaving", false); + } + }, + async downloadFileAttachment({ commit }, fileId: string) { + commit("setFileAttachmentLoading", true); + commit("setFileAttachmentLoadingError", null); + try { + const response = await authClient.irisMessageFileDownload(fileId); + const fileName = extractFileName(response); + fileUtil.download(response.data, fileName); + } catch (e) { + commit("setFileAttachmentLoadingError", getErrorMessage(e)); + } finally { + commit("setFileAttachmentLoading", false); + } + }, + }, +}; +const extractFileName = (response: AxiosResponse): string => { + const fileName = (response.headers["content-disposition"] || "") + .split("filename=")[1] + .split(";")[0] + .replace(/['"]/g, ""); + if (fileName.length <= 0) { + throw new Error("invalid file name"); + } + return fileName; +}; +export default irisMessageDetails; diff --git a/iris-client-fe/src/views/iris-message-details/iris-message-details.view.vue b/iris-client-fe/src/views/iris-message-details/iris-message-details.view.vue new file mode 100644 index 000000000..9623eb6e7 --- /dev/null +++ b/iris-client-fe/src/views/iris-message-details/iris-message-details.view.vue @@ -0,0 +1,163 @@ + + + + + {{ message.createdAt }} + + + {{ message.subject }} + + + + + Von: + {{ message.author }} + + + An: + {{ message.recipient }} + + + + + {{ message.body }} + + + + Anhang + + + + + mdi-download + + + + {{ fileAttachment.name }} + + + {{ fileAttachment.type }} + + + + + + + + + + Zurück + + + + + + + diff --git a/iris-client-fe/src/views/iris-message-list/components/iris-message-data-table.vue b/iris-client-fe/src/views/iris-message-list/components/iris-message-data-table.vue new file mode 100644 index 000000000..964495c70 --- /dev/null +++ b/iris-client-fe/src/views/iris-message-list/components/iris-message-data-table.vue @@ -0,0 +1,124 @@ + + + + + mdi-paperclip + + + mdi-paperclip + + + + + + Bitte wählen Sie einen Ordner aus + + + + diff --git a/iris-client-fe/src/views/iris-message-list/components/iris-message-folders-data-tree.vue b/iris-client-fe/src/views/iris-message-list/components/iris-message-folders-data-tree.vue new file mode 100644 index 000000000..b13768eb4 --- /dev/null +++ b/iris-client-fe/src/views/iris-message-list/components/iris-message-folders-data-tree.vue @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + diff --git a/iris-client-fe/src/views/iris-message-list/components/iris-message-list-nav-link.vue b/iris-client-fe/src/views/iris-message-list/components/iris-message-list-nav-link.vue new file mode 100644 index 000000000..1f7e3fdb6 --- /dev/null +++ b/iris-client-fe/src/views/iris-message-list/components/iris-message-list-nav-link.vue @@ -0,0 +1,75 @@ + + + + {{ link.meta.menuName }} + + + + + + + diff --git a/iris-client-fe/src/views/iris-message-list/iris-message-list.data.ts b/iris-client-fe/src/views/iris-message-list/iris-message-list.data.ts new file mode 100644 index 000000000..374fd41ab --- /dev/null +++ b/iris-client-fe/src/views/iris-message-list/iris-message-list.data.ts @@ -0,0 +1,136 @@ +import { + IrisMessage, + IrisMessageContext, + IrisMessageFolder, + IrisMessageHdContact, + PageIrisMessages, +} from "@/api"; +import { Complete, normalizeData, normalizeValue } from "@/utils/data"; + +export const normalizeUnreadIrisMessageCount = ( + source?: number, + parse?: boolean +): number => { + return normalizeValue( + source, + (normalizer) => { + return normalizer(0, "number"); + }, + parse, + "UnreadIrisMessageCount" + ); +}; + +const normalizeIrisMessageFolder = ( + source?: IrisMessageFolder, + parse?: boolean +): IrisMessageFolder => { + return normalizeData( + source, + (normalizer) => { + const normalized: Complete = { + id: normalizer("id", ""), + name: normalizer("name", ""), + items: source?.items + ? normalizeIrisMessageFolders(source?.items) + : undefined, + context: normalizer("context", IrisMessageContext.Unknown), + isDefault: normalizer("isDefault", undefined, "boolean"), + }; + return normalized; + }, + parse, + "IrisMessageFolder" + ); +}; + +export const normalizeIrisMessageFolders = ( + source?: IrisMessageFolder[], + parse?: boolean +): IrisMessageFolder[] => { + return normalizeData( + source, + () => { + return (source || []).map((item) => normalizeIrisMessageFolder(item)); + }, + parse, + "IrisMessageFolders" + ); +}; + +export const normalizeIrisMessageHdContact = ( + source?: IrisMessageHdContact, + parse?: boolean +): IrisMessageHdContact => { + return normalizeData( + source, + (normalizer) => { + const normalized: Complete = { + id: normalizer("id", ""), + name: normalizer("name", ""), + isOwn: normalizer("isOwn", undefined, "boolean"), + }; + return normalized; + }, + parse, + "IrisMessageHdContact" + ); +}; + +export const normalizeIrisMessage = ( + source?: IrisMessage, + parse?: boolean +): IrisMessage => { + return normalizeData( + source, + (normalizer) => { + const normalized: Complete = { + id: normalizer("id", ""), + folder: normalizer("folder", ""), + context: normalizer("context", IrisMessageContext.Unknown), + subject: normalizer("subject", ""), + body: normalizer("body", ""), + hdAuthor: normalizeIrisMessageHdContact(source?.hdAuthor), + hdRecipient: normalizeIrisMessageHdContact(source?.hdRecipient), + createdAt: normalizer("createdAt", "", "dateString"), + isRead: normalizer("isRead", undefined, "boolean"), + hasFileAttachments: normalizer( + "hasFileAttachments", + undefined, + "boolean" + ), + }; + return normalized; + }, + parse, + "IrisMessage" + ); +}; + +export const normalizePageIrisMessages = ( + source?: PageIrisMessages, + parse?: boolean +): PageIrisMessages => { + return normalizeData( + source, + (normalizer) => { + const content = normalizer("content", [], "array"); + const normalized: Complete = { + totalElements: normalizer("totalElements", undefined, "number"), + totalPages: normalizer("totalPages", undefined, "number"), + size: normalizer("size", undefined, "number"), + content: content.map((item) => normalizeIrisMessage(item)), + number: normalizer("number", undefined, "number"), + sort: normalizer("sort", undefined, "any"), + first: normalizer("first", undefined, "boolean"), + last: normalizer("last", undefined, "boolean"), + numberOfElements: normalizer("numberOfElements", undefined, "number"), + pageable: normalizer("pageable", undefined, "any"), + empty: normalizer("empty", undefined, "boolean"), + }; + return normalized; + }, + parse, + "PageIndexCase" + ); +}; diff --git a/iris-client-fe/src/views/iris-message-list/iris-message-list.store.ts b/iris-client-fe/src/views/iris-message-list/iris-message-list.store.ts new file mode 100644 index 000000000..1c9da9266 --- /dev/null +++ b/iris-client-fe/src/views/iris-message-list/iris-message-list.store.ts @@ -0,0 +1,162 @@ +import { RootState } from "@/store/types"; + +import { Commit, Module } from "vuex"; +import { IrisMessageFolder, IrisMessageQuery, PageIrisMessages } from "@/api"; +import authClient from "@/api-client"; +import { ErrorMessage, getErrorMessage } from "@/utils/axios"; +import { + normalizeIrisMessageFolders, + normalizePageIrisMessages, + normalizeUnreadIrisMessageCount, +} from "@/views/iris-message-list/iris-message-list.data"; + +export type IrisMessageListState = { + messageList: PageIrisMessages | null; + messageListLoading: boolean; + messageListLoadingError: ErrorMessage; + messageFolders: IrisMessageFolder[] | null; + messageFoldersLoading: boolean; + messageFoldersLoadingError: ErrorMessage; + unreadMessageCount: number; + unreadMessageCountLoading: boolean; +}; + +export interface IrisMessageListModule + extends Module { + mutations: { + setMessageList( + state: IrisMessageListState, + payload: PageIrisMessages + ): void; + setMessageListLoading(state: IrisMessageListState, payload: boolean): void; + setMessageListLoadingError( + state: IrisMessageListState, + payload: ErrorMessage + ): void; + setMessageFolders( + state: IrisMessageListState, + payload: IrisMessageFolder[] + ): void; + setMessageFoldersLoading( + state: IrisMessageListState, + payload: boolean + ): void; + setMessageFoldersLoadingError( + state: IrisMessageListState, + payload: ErrorMessage + ): void; + setUnreadMessageCount(state: IrisMessageListState, payload: number): void; + setUnreadMessageCountLoading( + state: IrisMessageListState, + payload: boolean + ): void; + reset(state: IrisMessageListState, payload: null): void; + }; + actions: { + fetchMessages( + { commit }: { commit: Commit }, + payload: IrisMessageQuery + ): Promise; + fetchMessageFolders({ commit }: { commit: Commit }): Promise; + fetchUnreadMessageCount({ commit }: { commit: Commit }): Promise; + }; +} + +const defaultState: IrisMessageListState = { + messageList: { + content: [], + totalElements: 0, + }, + messageListLoading: false, + messageListLoadingError: null, + messageFolders: null, + messageFoldersLoading: false, + messageFoldersLoadingError: null, + unreadMessageCount: 0, + unreadMessageCountLoading: false, +}; + +const irisMessageList: IrisMessageListModule = { + namespaced: true, + state() { + return { ...defaultState }; + }, + mutations: { + setMessageList(state, payload) { + state.messageList = payload; + }, + setMessageListLoading(state, payload) { + state.messageListLoading = payload; + }, + setMessageListLoadingError(state, payload) { + state.messageListLoadingError = payload; + }, + setMessageFolders(state, payload) { + state.messageFolders = payload; + }, + setMessageFoldersLoading(state, payload) { + state.messageFoldersLoading = payload; + }, + setMessageFoldersLoadingError(state, payload) { + state.messageFoldersLoadingError = payload; + }, + setUnreadMessageCount(state, payload) { + state.unreadMessageCount = payload; + }, + setUnreadMessageCountLoading(state, payload) { + state.unreadMessageCountLoading = payload; + }, + reset(state) { + Object.assign(state, { ...defaultState }); + }, + }, + actions: { + async fetchMessages({ commit }, query: IrisMessageQuery) { + let list: PageIrisMessages | null = null; + commit("setMessageListLoading", true); + commit("setMessageListLoadingError", null); + try { + list = normalizePageIrisMessages( + (await authClient.irisMessagesGet({ params: query })).data, + true + ); + } catch (e) { + commit("setMessageListLoadingError", getErrorMessage(e)); + } finally { + commit("setMessageList", list); + commit("setMessageListLoading", false); + } + }, + async fetchMessageFolders({ commit }) { + let list: IrisMessageFolder[] | null = null; + commit("setMessageFoldersLoading", true); + commit("setMessageFoldersLoadingError", null); + try { + list = normalizeIrisMessageFolders( + (await authClient.irisMessageFoldersGet()).data, + true + ); + } catch (e) { + commit("setMessageFoldersLoadingError", getErrorMessage(e)); + } finally { + commit("setMessageFolders", list); + commit("setMessageFoldersLoading", false); + } + }, + async fetchUnreadMessageCount({ commit }) { + let count = 0; + commit("setUnreadMessageCountLoading", true); + try { + count = normalizeUnreadIrisMessageCount( + (await authClient.irisUnreadMessageCountGet()).data, + true + ); + } finally { + commit("setUnreadMessageCount", count); + commit("setUnreadMessageCountLoading", false); + } + }, + }, +}; + +export default irisMessageList; diff --git a/iris-client-fe/src/views/iris-message-list/iris-message-list.view.vue b/iris-client-fe/src/views/iris-message-list/iris-message-list.view.vue new file mode 100644 index 000000000..8f2265b50 --- /dev/null +++ b/iris-client-fe/src/views/iris-message-list/iris-message-list.view.vue @@ -0,0 +1,149 @@ + + + + + Nachricht schreiben + + + + Nachrichten + + + + + + + + + + + + + + diff --git a/iris-client-fe/tests/e2e/specs/app-bar.js b/iris-client-fe/tests/e2e/specs/app-bar.js index 2f4cba579..7044ed913 100644 --- a/iris-client-fe/tests/e2e/specs/app-bar.js +++ b/iris-client-fe/tests/e2e/specs/app-bar.js @@ -5,6 +5,7 @@ describe("AppBar", () => { dashboard: "/", "event-list": "/events/list", "index-list": "/cases/list", + "iris-message-list": "/iris-messages/list", about: "/about", }; beforeEach(() => { diff --git a/iris-client-fe/tests/e2e/specs/iris-messages.js b/iris-client-fe/tests/e2e/specs/iris-messages.js new file mode 100644 index 000000000..9ae2606f9 --- /dev/null +++ b/iris-client-fe/tests/e2e/specs/iris-messages.js @@ -0,0 +1,128 @@ +import _shuffle from "lodash/shuffle"; +import _sampleSize from "lodash/sampleSize"; +import _random from "lodash/random"; + +const generateRandomText = (key = "", size) => { + const chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split(""); + return "E2E" + key + _shuffle(_sampleSize(chars, size)).join(""); +}; + +describe("IrisMessages", () => { + const timestamp = new Date().getTime(); + const message = { + subject: generateRandomText(`Subject_${timestamp}_`, _random(10, 15)), + body: generateRandomText(`Body_${timestamp}_`, _random(10, 150)), + }; + beforeEach(() => { + cy.clearLocalStorage(); + cy.visit("/"); + cy.login(); + }); + afterEach(() => { + cy.logout(); + }); + it("should display the message folders and change the context", () => { + cy.visit("/iris-messages/list"); + cy.getRootMessageFolder("INBOX").should("have.class", "v-btn--active"); + cy.getRootMessageFolder("OUTBOX").should("not.have.class", "v-btn--active"); + cy.getRootMessageFolder("INBOX").click(); + cy.getBy("view.data-table") + .should("exist") + .get(".v-data-table-header") + .should("contain", "Von") + .should("not.contain", "An"); + cy.getRootMessageFolder("OUTBOX").click(); + cy.getBy("view.data-table") + .should("exist") + .get(".v-data-table-header") + .should("contain", "An") + .should("not.contain", "Von"); + }); + it("should display the create message link, navigate to the message creation page and cancel the message creation", () => { + cy.visit("/iris-messages/list"); + cy.getBy("view.link.create") + .should("have.attr", "href", "/iris-messages/create") + .click(); + cy.location("pathname").should("equal", "/iris-messages/create"); + cy.get("form").within(() => { + cy.getBy(".v-btn{cancel}").should("exist").click(); + }); + cy.location("pathname").should("equal", "/iris-messages/list"); + }); + it("should validate the message creation form", () => { + cy.visit("/iris-messages/create"); + cy.get("form") + .should("exist") + .within(() => { + cy.getBy(".v-btn{submit}").should("exist").click(); + cy.assertInputInvalidByRule("input{hdRecipient}"); + cy.assertInputInvalidByRule("input{subject}") + .type("-") + .assertInputInvalidByRule("sanitised"); + cy.assertInputInvalidByRule("textarea{body}") + .type("-") + .assertInputInvalidByRule("sanitised"); + cy.assertInputValid("input{fileAttachments}"); + cy.selectOwnIrisMessageContact( + "input{hdRecipient}", + ".select-menu-recipient" + ); + }); + }); + it("should create a new message and display the message details", () => { + cy.visit("/iris-messages/create"); + cy.get("form") + .should("exist") + .within(() => { + cy.selectOwnIrisMessageContact( + "input{hdRecipient}", + ".select-menu-recipient" + ); + cy.getBy("input{subject}").should("exist").type(message.subject); + cy.getBy("textarea{body}").should("exist").type(message.body); + cy.getBy(".v-btn{submit}").should("exist").click(); + }); + cy.location("pathname").should("equal", "/iris-messages/list"); + cy.getMessageDataTableRow(message.subject, "OUTBOX") + .should("not.have.class", "font-weight-bold") + .click(); + cy.location("pathname").should("contain", "/iris-messages/details"); + cy.getBy("view.iris-message-details").should( + "not.have.class", + "v-card--loading" + ); + cy.getBy("message.createdAt").should("not.be.empty"); + cy.getBy("message.author").should("not.be.empty"); + cy.getBy("message.recipient").should("not.be.empty"); + cy.getBy("message.subject").should("contain", message.subject); + cy.getBy("message.body").should("contain", message.body); + }); + it("should display the unread message count in a badge in the app-bar and and decrease it by opening an unread message", () => { + cy.visit("/iris-messages/list"); + cy.getBy("app-bar.nav.link.iris-message-list") + .as("navLink") + .should("not.have.class", "is-loading"); + cy.getBy("{iris-messages.unread.count} .v-badge__badge") + .as("badge") + .should("be.visible") + .invoke("text") + .then((textContent) => { + const count = parseInt(textContent); + expect(count).to.be.gte(1); + cy.getRootMessageFolder("INBOX").click(); + cy.get(".v-data-table tr.font-weight-bold").should("exist").click(); + cy.location("pathname").should("contain", "/iris-messages/details"); + cy.getBy("view.iris-message-details").should( + "not.have.class", + "v-card--loading" + ); + cy.get("@navLink").should("not.have.class", "is-loading"); + if (count <= 1) { + cy.get("@badge").should("not.be.visible"); + } else { + cy.get("@badge").invoke("text").then(parseInt).should("be.lt", count); + } + }); + }); +}); diff --git a/iris-client-fe/tests/e2e/support/commands.js b/iris-client-fe/tests/e2e/support/commands.js index 2a31b73ec..817d7c5d6 100644 --- a/iris-client-fe/tests/e2e/support/commands.js +++ b/iris-client-fe/tests/e2e/support/commands.js @@ -168,11 +168,14 @@ Cypress.Commands.add( ); Cypress.Commands.add("getDataTableRow", (accessor, table) => { + cy.getBy(table || ".v-data-table").as("dataTable"); + cy.get("@dataTable").should("not.have.class", "is-loading"); cy.getBy("input{search}") .should("exist") .clear() .type(accessor, { log: false }); - cy.getBy(table || ".v-data-table") + cy.get("@dataTable") + .should("not.have.class", "is-loading") .contains(accessor, { log: false }) .closest("tr"); }); @@ -191,6 +194,70 @@ Cypress.Commands.add("visitUserByAccessor", (accessor) => { cy.location("pathname").should("contain", "/admin/user/edit"); }); +Cypress.Commands.add("getRootMessageFolder", (context) => { + cy.location("pathname").should("equal", "/iris-messages/list"); + cy.getBy("message-folders-data-tree").should("exist"); + return cy + .getBy(`{message-folders-data-tree} {select.${context}}`) + .should("exist") + .first(); +}); + +Cypress.Commands.add("getMessageDataTableRow", (accessor, context) => { + cy.location("pathname").should("equal", "/iris-messages/list"); + cy.getRootMessageFolder(context).click(); + cy.getDataTableRow(accessor, "view.data-table").should("exist"); +}); + +Cypress.Commands.add( + "selectOwnIrisMessageContact", + { prevSubject: "optional" }, + (subject, arg1, arg2) => { + // arg1 = selector, arg2 = menu + const menu = subject ? arg1 : arg2; + if (subject) { + cy.wrap(subject).as("field"); + } else { + cy.getBy(arg1).as("field"); + } + cy.get("@field").closest(".v-input").should("not.have.class", "is-empty"); + cy.getApp().then((app) => { + const contacts = app.$store.state.irisMessageCreate.contacts; + const ownContact = contacts.find((c) => c.isOwn === true); + cy.wrap(ownContact).should("exist").should("not.be.empty"); + cy.get("@field").selectAutocompleteValue(menu, ownContact.name); + }); + return cy.get("@field"); + } +); + +Cypress.Commands.add( + "selectAutocompleteValue", + { prevSubject: "optional" }, + (subject, arg1, arg2, arg3) => { + // arg1 = selector, arg2 = menu, arg3 = value + const menu = subject ? arg1 : arg2; + const value = subject ? arg2 : arg3; + if (subject) { + cy.wrap(subject).as("field"); + } else { + cy.getBy(arg1).as("field"); + } + cy.get("@field") + .type(value) + .closest(".v-input") + .click() + .closest("#app") + .find(menu) + .should("exist") + .within(() => { + cy.contains(value).as("value").click(); + }); + cy.get("@field").assertInputValid().should("have.value", value); + return cy.get("@field"); + } +); + Cypress.Commands.add( "selectFieldValue", { prevSubject: "optional" }, diff --git a/iris-client-fe/tests/e2e/support/index.d.ts b/iris-client-fe/tests/e2e/support/index.d.ts index 06cf2043d..6c3168729 100644 --- a/iris-client-fe/tests/e2e/support/index.d.ts +++ b/iris-client-fe/tests/e2e/support/index.d.ts @@ -1,79 +1,104 @@ /// -declare namespace Cypress { - interface Chainable { - logout(): Chainable; - login(credentials?: { - userName: string; - password: string; - }): Chainable; - loginUsingUi(username: string, password: string): Chainable; - fetchUser(): Chainable; - assertInputValid(selector?: string): Chainable; - assertInputInvalid(message?: string): Chainable; - assertInputInvalid(selector: string, message?: string): Chainable; - assertInputInvalidByRule(rule?: string): Chainable; - assertInputInvalidByRule( - selector: string, - rule?: string - ): Chainable; - getApp(): Chainable; - filterDataTableByStatus(status: string): Chainable; - filterDataTableByStatus( - selector: string, - status: string - ): Chainable; - visitByStatus(status: string): Chainable; - visitByStatus(selector: string, status: string): Chainable; - visitUserByAccessor(accessor: string): Chainable; - checkTooltip(tooltip: string): Chainable; - checkTooltip(selector: string, tooltip: string): Chainable; - validateDateTimeField(required?: boolean): Chainable; - validateDateTimeField( - selector: string, - required?: boolean - ): Chainable; - setDateTimeFieldValue(date: dayjs.ConfigType): Chainable; - setDateTimeFieldValue( - selector: string, - date: dayjs.ConfigType - ): Chainable; - getDataTableRow(accessor: string, table?: string): Chainable; - editInputField( - selector: string, - config?: { - text?: string; - action?: "add" | "remove"; - validation?: Array<"defined" | "sanitised">; - } - ): Chainable; - selectFieldValue(menu: string, value: string): Chainable; - selectFieldValue( - selector: string, - menu: string, - value: string - ): Chainable; - checkEditableField( - selector: string, - config?: { - field?: string; - validation?: Array<"defined" | "sanitised">; - } - ): Chainable; - getBy( - selector: string, - options?: Partial - ): Chainable>; - getByLike( - selector: string, - options?: Partial - ): Chainable>; - changeOwnPassword( - credentials: { +import Vue from "vue"; +import * as dayjs from "dayjs"; +import { RootState } from "../../../src/store/types"; + +declare global { + namespace Cypress { + interface Chainable { + logout(): Chainable; + login(credentials?: { userName: string; password: string; - }, - newPassword: string - ): Chainable; + }): Chainable; + loginUsingUi(username: string, password: string): Chainable; + fetchUser(): Chainable; + assertInputValid(selector?: string): Chainable; + assertInputInvalid(message?: string): Chainable; + assertInputInvalid( + selector: string, + message?: string + ): Chainable; + assertInputInvalidByRule(rule?: string): Chainable; + assertInputInvalidByRule( + selector: string, + rule?: string + ): Chainable; + getApp(): Chainable<(Vue & { $store: { state: RootState } }) | undefined>; + filterDataTableByStatus(status: string): Chainable; + filterDataTableByStatus( + selector: string, + status: string + ): Chainable; + visitByStatus(status: string): Chainable; + visitByStatus(selector: string, status: string): Chainable; + visitUserByAccessor(accessor: string): Chainable; + getRootMessageFolder(context: "INBOX" | "OUTBOX"): Chainable; + getMessageDataTableRow( + accessor: string, + context: "INBOX" | "OUTBOX" + ): Chainable; + checkTooltip(tooltip: string): Chainable; + checkTooltip(selector: string, tooltip: string): Chainable; + validateDateTimeField(required?: boolean): Chainable; + validateDateTimeField( + selector: string, + required?: boolean + ): Chainable; + setDateTimeFieldValue(date: dayjs.ConfigType): Chainable; + setDateTimeFieldValue( + selector: string, + date: dayjs.ConfigType + ): Chainable; + getDataTableRow(accessor: string, table?: string): Chainable; + editInputField( + selector: string, + config?: { + text?: string; + action?: "add" | "remove"; + validation?: Array<"defined" | "sanitised">; + } + ): Chainable; + selectFieldValue(menu: string, value: string): Chainable; + selectFieldValue( + selector: string, + menu: string, + value: string + ): Chainable; + selectOwnIrisMessageContact(menu: string): Chainable; + selectOwnIrisMessageContact( + selector: string, + menu: string + ): Chainable; + selectAutocompleteValue(menu: string, value: string): Chainable; + selectAutocompleteValue( + selector: string, + menu: string, + value: string + ): Chainable; + checkEditableField( + selector: string, + config?: { + field?: string; + validation?: Array<"defined" | "sanitised">; + } + ): Chainable; + getBy( + selector: string, + options?: Partial + ): Chainable; + getByLike( + selector: string, + options?: Partial + ): Chainable; + changeOwnPassword( + credentials: { + userName: string; + password: string; + }, + newPassword: string + ): Chainable; + } } } diff --git a/iris-client-fe/tests/e2e/tsconfig.json b/iris-client-fe/tests/e2e/tsconfig.json new file mode 100644 index 000000000..8e06d7cb8 --- /dev/null +++ b/iris-client-fe/tests/e2e/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress"], + "esModuleInterop": true + }, + "include": ["**/*.ts"] +} \ No newline at end of file
Anhang