From ca91759607b1ddbbc1007d2f9c3e9cba95fe671d Mon Sep 17 00:00:00 2001 From: devinleighsmith Date: Tue, 27 Jan 2026 18:06:03 -0800 Subject: [PATCH 1/4] psp-10851 document content search --- .../api/Models/Configuration/MayanConfig.cs | 2 + .../Mayan/IEdmsDocumentRepository.cs | 7 +- .../Mayan/MayanDocumentRepository.cs | 21 ++ .../backend/api/Services/DocumentService.cs | 51 ++++- .../backend/api/Services/IDocumentService.cs | 9 +- source/backend/api/Startup.cs | 1 + source/backend/api/appsettings.json | 3 +- .../Document/DocumentSearchFilterModel.cs | 10 + .../Document/AdvancedSearchResponseModel.cs | 50 +++++ .../dal/Repositories/DocumentRepository.cs | 5 + .../tests/api/Services/DocumentServiceTest.cs | 188 ++++++++++++++++++ .../Repositories/DocumentRepositoryTest.cs | 88 ++++++++ .../DocumentSearchResults.tsx | 31 ++- .../DocumentSearchFilter.tsx | 43 ++-- .../documents/models/DocumentFilterModel.ts | 2 + source/frontend/src/hooks/useSearch.ts | 20 +- .../ApiGen_Concepts_DocumentSearchFilter.ts | 1 + tools/mayan-edms/.mayan-env | 4 +- tools/mayan-edms/docker-compose.yml | 11 + 19 files changed, 513 insertions(+), 34 deletions(-) create mode 100644 source/backend/apimodels/Models/Mayan/Document/AdvancedSearchResponseModel.cs diff --git a/source/backend/api/Models/Configuration/MayanConfig.cs b/source/backend/api/Models/Configuration/MayanConfig.cs index bd2b334b16..ef03062921 100644 --- a/source/backend/api/Models/Configuration/MayanConfig.cs +++ b/source/backend/api/Models/Configuration/MayanConfig.cs @@ -17,5 +17,7 @@ public class MayanConfig public int ImageRetries { get; set; } public int PreviewPages { get; set; } + + public int MaxContentResults { get; set; } } } diff --git a/source/backend/api/Repositories/Mayan/IEdmsDocumentRepository.cs b/source/backend/api/Repositories/Mayan/IEdmsDocumentRepository.cs index f25e21852c..f4feb3c6f9 100644 --- a/source/backend/api/Repositories/Mayan/IEdmsDocumentRepository.cs +++ b/source/backend/api/Repositories/Mayan/IEdmsDocumentRepository.cs @@ -1,10 +1,11 @@ -using System.Net.Http; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Pims.Api.Models; using Pims.Api.Models.Mayan; using Pims.Api.Models.Mayan.Document; +using Pims.Api.Models.Models.Mayan.Document; using Pims.Api.Models.Requests.Http; +using System.Net.Http; +using System.Threading.Tasks; namespace Pims.Api.Repositories.Mayan { @@ -56,5 +57,7 @@ public interface IEdmsDocumentRepository Task>> TryGetFilePageListAsync(long documentId, long documentFileId, int pageSize, int pageNumber); Task TryGetFilePageImage(long documentId, long documentFileId, long documentFilePageId); + + Task>> TrySearchByDocumentContent(string contentToSearch); } } diff --git a/source/backend/api/Repositories/Mayan/MayanDocumentRepository.cs b/source/backend/api/Repositories/Mayan/MayanDocumentRepository.cs index cab12c42fe..6bfe9431c9 100644 --- a/source/backend/api/Repositories/Mayan/MayanDocumentRepository.cs +++ b/source/backend/api/Repositories/Mayan/MayanDocumentRepository.cs @@ -9,6 +9,7 @@ using Pims.Api.Models.Mayan; using Pims.Api.Models.Mayan.Document; using Pims.Api.Models.Mayan.Metadata; +using Pims.Api.Models.Models.Mayan.Document; using Pims.Api.Models.Requests.Http; using Polly.Registry; using System; @@ -429,6 +430,26 @@ public async Task TryGetFilePageImage(long documentId, long return response; } + public async Task>> TrySearchByDocumentContent(string contentToSearch) + { + _logger.LogDebug("Retrieving all mayan documents that contain string {ContentToSearch}", contentToSearch); + string authenticationToken = await _authRepository.GetTokenAsync(); + + string endpointString = $"{_config.BaseUri}/search/advanced/documents.documentsearchresult/"; + var queryParams = new System.Collections.Generic.Dictionary + { + { "_match_all", "false" }, + { "files__file_pages__content__content", $"\"{contentToSearch}\"" }, + }; // multi-word searches need to be wrapped in quotes, as mayan will search each word separately otherwise - which has unacceptable performance. + + Uri endpoint = new(QueryHelpers.AddQueryString(endpointString, queryParams)); + + var response = await GetAsync>(endpoint, authenticationToken); + + _logger.LogDebug("Finished retrieving mayan search results for string {ContentToSearch}", contentToSearch); + return response; + } + private async Task GetFileAsync(long documentId, long fileId) { string authenticationToken = await _authRepository.GetTokenAsync(); diff --git a/source/backend/api/Services/DocumentService.cs b/source/backend/api/Services/DocumentService.cs index 9ba7840707..8b66521f4c 100644 --- a/source/backend/api/Services/DocumentService.cs +++ b/source/backend/api/Services/DocumentService.cs @@ -17,6 +17,7 @@ using Pims.Api.Models.Config; using Pims.Api.Models.Mayan; using Pims.Api.Models.Mayan.Document; +using Pims.Api.Models.Models.Mayan.Document; using Pims.Api.Models.Requests.Document.UpdateMetadata; using Pims.Api.Models.Requests.Document.Upload; using Pims.Api.Models.Requests.Http; @@ -41,6 +42,7 @@ namespace Pims.Api.Services public class DocumentService : BaseService, IDocumentService { protected const string MayanGenericErrorMessage = "Error response received from Mayan Document Service"; + protected const int ContentSearchLimit = 2000; private static readonly string[] ValidExtensions = { "txt", @@ -74,6 +76,7 @@ public class DocumentService : BaseService, IDocumentService private readonly IAvService avService; private readonly IMapper mapper; private readonly IOptionsMonitor keycloakOptions; + private readonly IOptionsMonitor mayanOptions; public DocumentService( ClaimsPrincipal user, @@ -85,6 +88,7 @@ public DocumentService( IAvService avService, IMapper mapper, IOptionsMonitor options, + IOptionsMonitor mayanOptions, IDocumentQueueRepository queueRepository) : base(user, logger) { @@ -94,7 +98,7 @@ public DocumentService( this.avService = avService; this.mapper = mapper; this.keycloakOptions = options; - _config = new MayanConfig(); + this.mayanOptions = mayanOptions; configuration.Bind(MayanConfigSectionKey, _config); } @@ -152,6 +156,41 @@ public Paged GetPage(DocumentSearchFilterModel filter) User.ThrowIfNotAuthorized(Permissions.DocumentView); + ArgumentNullException.ThrowIfNull(filter); + + if (!string.IsNullOrWhiteSpace(filter.Content)) + { + var contentSearchResponse = SearchDocumentContent(filter.Content) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + + if (contentSearchResponse.Status != ExternalResponseStatus.Success) + { + throw GetMayanResponseError(contentSearchResponse.Message); + } + + var mayanIds = contentSearchResponse.Payload?.Results? + .Where(r => r?.Id != null) + .Select(r => (long)r.Id.Value) + .Distinct() + .ToArray() ?? Array.Empty(); + + var maxContentResults = mayanOptions.CurrentValue?.MaxContentResults ?? ContentSearchLimit; + if (mayanIds.Length > maxContentResults) + { + throw new BadRequestException( + $"Document content search returned more than {maxContentResults:N0} matches. Please refine your search criteria."); + } + + filter.MayanDocumentIds = mayanIds; + + if (mayanIds.Length == 0) + { + return new Paged(Array.Empty(), filter.Page, filter.Quantity, 0); + } + } + return documentRepository.GetPageDeep(filter); } @@ -654,6 +693,16 @@ public async Task DownloadFilePageImageAsync(long mayanDocu return result; } + public async Task>> SearchDocumentContent(string contentToSearch) + { + this.Logger.LogInformation("Searching for document file content: {ContentToSearch}", contentToSearch); + this.User.ThrowIfNotAuthorized(Permissions.DocumentView); + + var result = await documentStorageRepository.TrySearchByDocumentContent(contentToSearch); + + return result; + } + private async Task PrecacheDocumentPreviews(long documentId, long documentFileId) { this.Logger.LogInformation("Precaching the first {_config.PreviewPages} pages in document {documentId}, file {documentFileId}", _config.PreviewPages, documentId, documentFileId); diff --git a/source/backend/api/Services/IDocumentService.cs b/source/backend/api/Services/IDocumentService.cs index 770d2fa5d9..197b6bbef6 100644 --- a/source/backend/api/Services/IDocumentService.cs +++ b/source/backend/api/Services/IDocumentService.cs @@ -1,16 +1,17 @@ -using System.Collections.Generic; -using System.Net.Http; -using System.Threading.Tasks; using Pims.Api.Models; using Pims.Api.Models.CodeTypes; using Pims.Api.Models.Concepts.Document; using Pims.Api.Models.Mayan; using Pims.Api.Models.Mayan.Document; +using Pims.Api.Models.Models.Mayan.Document; using Pims.Api.Models.Requests.Document.UpdateMetadata; using Pims.Api.Models.Requests.Document.Upload; using Pims.Api.Models.Requests.Http; using Pims.Dal.Entities; using Pims.Dal.Entities.Models; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; namespace Pims.Api.Services { @@ -58,5 +59,7 @@ public interface IDocumentService Task DownloadFilePageImageAsync(long mayanDocumentId, long mayanFileId, long mayanFilePageId); PimsDocument AddDocument(PimsDocument newPimsDocument); + + Task>> SearchDocumentContent(string contentToSearch); } } diff --git a/source/backend/api/Startup.cs b/source/backend/api/Startup.cs index 66bf84bea0..cecbd2a0e4 100644 --- a/source/backend/api/Startup.cs +++ b/source/backend/api/Startup.cs @@ -139,6 +139,7 @@ public void ConfigureServices(IServiceCollection services) services.Configure(this.Configuration.GetSection("OpenIdConnect")); services.Configure(this.Configuration.GetSection("Keycloak")); services.Configure(this.Configuration.GetSection("Pims")); + services.Configure(this.Configuration.GetSection("Mayan")); services.Configure(this.Configuration.GetSection("HealthChecks")); services.AddOptions(); diff --git a/source/backend/api/appsettings.json b/source/backend/api/appsettings.json index e4cb0e613b..24dab38bca 100644 --- a/source/backend/api/appsettings.json +++ b/source/backend/api/appsettings.json @@ -144,7 +144,8 @@ "ExposeErrors": false, "UploadRetries": 4, "ImageRetries": 2, - "PreviewPages": 10 + "PreviewPages": 10, + "MaxContentResults": 2000 }, "Cdogs": { "AuthEndpoint": "[AUTH_ENDPOINT]", diff --git a/source/backend/apimodels/Models/Concepts/Document/DocumentSearchFilterModel.cs b/source/backend/apimodels/Models/Concepts/Document/DocumentSearchFilterModel.cs index 4d064d46a5..a062360da4 100644 --- a/source/backend/apimodels/Models/Concepts/Document/DocumentSearchFilterModel.cs +++ b/source/backend/apimodels/Models/Concepts/Document/DocumentSearchFilterModel.cs @@ -38,6 +38,16 @@ public DocumentSearchFilterModel() /// public string Plan { get; set; } + /// + /// get/set - The string content to search by. + /// + public string Content { get; set; } + + /// + /// get/set - The Mayan document identifiers that match an external content search. + /// + public long[] MayanDocumentIds { get; set; } + /// /// Determine if a valid filter was provided. /// diff --git a/source/backend/apimodels/Models/Mayan/Document/AdvancedSearchResponseModel.cs b/source/backend/apimodels/Models/Mayan/Document/AdvancedSearchResponseModel.cs new file mode 100644 index 0000000000..828984a468 --- /dev/null +++ b/source/backend/apimodels/Models/Mayan/Document/AdvancedSearchResponseModel.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Models.Mayan.Document +{ + /// + /// Best-effort model for items returned by: + /// /api/v4/search/advanced/documents.documentsearchresult/... + /// + /// Mayan versions/configs differ, so this includes ExtensionData to capture + /// unknown fields safely. + /// + public sealed class DocumentSearchResult + { + // Commonly present in document-ish payloads. + [JsonPropertyName("id")] + public int? Id { get; init; } + + [JsonPropertyName("uuid")] + public Guid? Uuid { get; init; } + + [JsonPropertyName("label")] + public string? Label { get; init; } + + [JsonPropertyName("description")] + public string? Description { get; init; } + + [JsonPropertyName("datetime_created")] + public DateTimeOffset? DateTimeCreated { get; init; } + + [JsonPropertyName("datetime_modified")] + public DateTimeOffset? DateTimeModified { get; init; } + + // Search endpoints sometimes include score / snippet-ish fields. + [JsonPropertyName("score")] + public double? Score { get; init; } + + [JsonPropertyName("highlights")] + public object? Highlights { get; init; } + + /// + /// Captures any additional fields returned by Mayan EDMS that are not explicitly modeled. + /// This makes your deserialization resilient to schema differences. + /// + [JsonExtensionData] + public Dictionary? AdditionalFields { get; init; } + } +} diff --git a/source/backend/dal/Repositories/DocumentRepository.cs b/source/backend/dal/Repositories/DocumentRepository.cs index 098c8fe6e1..89e868ef66 100644 --- a/source/backend/dal/Repositories/DocumentRepository.cs +++ b/source/backend/dal/Repositories/DocumentRepository.cs @@ -264,6 +264,11 @@ private IQueryable GetCommonQueryDeep(DocumentSearchFilterModel fi predicate = predicate.And(pd => pd.PimsPropertyDocuments.Any(p => p != null && EF.Functions.Like(p.Property.SurveyPlanNumber.ToString(), $"%{filter.Plan}%"))); } + if (filter.MayanDocumentIds?.Any() == true) + { + predicate = predicate.And(pd => pd.MayanId.HasValue && filter.MayanDocumentIds.Contains(pd.MayanId.Value)); + } + if(excludeTemplates) { var templateCodeType = Context.PimsDocumentTyps.FirstOrDefault(x => x.DocumentType == "CDOGTEMP"); diff --git a/source/backend/tests/api/Services/DocumentServiceTest.cs b/source/backend/tests/api/Services/DocumentServiceTest.cs index 50aee9bdc6..e03499e0fd 100644 --- a/source/backend/tests/api/Services/DocumentServiceTest.cs +++ b/source/backend/tests/api/Services/DocumentServiceTest.cs @@ -1,17 +1,21 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Threading.Tasks; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using Moq; using Pims.Api.Models; using Pims.Api.Models.CodeTypes; using Pims.Api.Models.Concepts.Document; +using Pims.Api.Models.Config; using Pims.Api.Models.Mayan; using Pims.Api.Models.Mayan.Document; using Pims.Api.Models.Mayan.Metadata; +using Pims.Api.Models.Models.Mayan.Document; using Pims.Api.Models.Requests.Document.UpdateMetadata; using Pims.Api.Models.Requests.Document.Upload; using Pims.Api.Models.Requests.Http; @@ -23,6 +27,7 @@ using Pims.Core.Security; using Pims.Core.Test; using Pims.Dal.Entities; +using Pims.Dal.Entities.Models; using Pims.Dal.Exceptions; using Pims.Dal.Repositories; using Xunit; @@ -62,6 +67,189 @@ private DocumentService CreateDocumentServiceWithPermissions(params Permissions[ return this._helper.Create(builder.Build()); } + private void ConfigureMayanMaxResults(int? maxResults) + { + var mayanOptions = this._helper.GetService>>(); + mayanOptions.Setup(m => m.CurrentValue).Returns(new MayanConfig + { + MaxContentResults = maxResults, + }); + } + + [Fact] + public void GetPage_ShouldThrowException_NotAuthorized() + { + var service = this.CreateDocumentServiceWithPermissions(); + var filter = new DocumentSearchFilterModel(); + + Action act = () => service.GetPage(filter); + + act.Should().Throw(); + } + + [Fact] + public void GetPage_ShouldThrowArgumentNullException_WhenFilterNull() + { + var service = this.CreateDocumentServiceWithPermissions(Permissions.DocumentView); + + Action act = () => service.GetPage(null); + + act.Should().Throw(); + } + + [Fact] + public void GetPage_NoContent_ReturnsRepositoryResult() + { + var service = this.CreateDocumentServiceWithPermissions(Permissions.DocumentView); + var documentRepository = this._helper.GetService>(); + var documentStorageRepository = this._helper.GetService>(); + var expected = new Paged(Array.Empty(), 2, 5, 0); + + documentRepository.Setup(r => r.GetPageDeep(It.Is(f => f.Page == 2 && f.Quantity == 5))) + .Returns(expected); + + var filter = new DocumentSearchFilterModel + { + Content = null, + Page = 2, + Quantity = 5, + }; + + var result = service.GetPage(filter); + + result.Should().BeSameAs(expected); + documentRepository.Verify(r => r.GetPageDeep(filter), Times.Once); + documentStorageRepository.Verify(r => r.TrySearchByDocumentContent(It.IsAny()), Times.Never); + } + + [Fact] + public void GetPage_ContentSearchSuccess_FiltersMayanIds() + { + var service = this.CreateDocumentServiceWithPermissions(Permissions.DocumentView); + var documentRepository = this._helper.GetService>(); + var documentStorageRepository = this._helper.GetService>(); + var expected = new Paged(Array.Empty(), 1, 10, 0); + var expectedMayanIds = new long[] { 1, 2 }; + + documentStorageRepository.Setup(r => r.TrySearchByDocumentContent("term")) + .ReturnsAsync(new ExternalResponse> + { + Status = ExternalResponseStatus.Success, + Payload = new QueryResponse + { + Count = 2, + Results = new List + { + new DocumentSearchResult { Id = 1 }, + new DocumentSearchResult { Id = 2 }, + }, + }, + }); + + documentRepository.Setup(r => r.GetPageDeep(It.Is(f => f.MayanDocumentIds != null && f.MayanDocumentIds.SequenceEqual(expectedMayanIds)))) + .Returns(expected); + + var filter = new DocumentSearchFilterModel + { + Content = "term", + Page = 1, + Quantity = 10, + }; + + var result = service.GetPage(filter); + + result.Should().BeSameAs(expected); + documentStorageRepository.Verify(r => r.TrySearchByDocumentContent("term"), Times.Once); + documentRepository.VerifyAll(); + } + + [Fact] + public void GetPage_ContentSearchReturnsNoMatches_ReturnsEmptyPage() + { + var service = this.CreateDocumentServiceWithPermissions(Permissions.DocumentView); + var documentStorageRepository = this._helper.GetService>(); + var documentRepository = this._helper.GetService>(); + + documentStorageRepository.Setup(r => r.TrySearchByDocumentContent("term")) + .ReturnsAsync(new ExternalResponse> + { + Status = ExternalResponseStatus.Success, + Payload = new QueryResponse + { + Count = 0, + Results = new List(), + }, + }); + + var filter = new DocumentSearchFilterModel + { + Content = "term", + Page = 3, + Quantity = 7, + }; + + var result = service.GetPage(filter); + + result.Count.Should().Be(0); + result.Total.Should().Be(0); + result.Page.Should().Be(3); + result.Quantity.Should().Be(7); + documentRepository.Verify(r => r.GetPageDeep(It.IsAny()), Times.Never); + } + + [Fact] + public void GetPage_ContentSearchFails_ThrowsMayanRepositoryException() + { + var service = this.CreateDocumentServiceWithPermissions(Permissions.DocumentView); + var documentStorageRepository = this._helper.GetService>(); + var documentRepository = this._helper.GetService>(); + + documentStorageRepository.Setup(r => r.TrySearchByDocumentContent("term")) + .ReturnsAsync(new ExternalResponse> + { + Status = ExternalResponseStatus.Error, + Message = "boom", + }); + + var filter = new DocumentSearchFilterModel { Content = "term" }; + + Action act = () => service.GetPage(filter); + + act.Should().Throw(); + documentRepository.Verify(r => r.GetPageDeep(It.IsAny()), Times.Never); + } + + [Fact] + public void GetPage_ContentSearchExceedsLimit_ThrowsBadRequestException() + { + var service = this.CreateDocumentServiceWithPermissions(Permissions.DocumentView); + ConfigureMayanMaxResults(1); + var documentStorageRepository = this._helper.GetService>(); + var documentRepository = this._helper.GetService>(); + + documentStorageRepository.Setup(r => r.TrySearchByDocumentContent("term")) + .ReturnsAsync(new ExternalResponse> + { + Status = ExternalResponseStatus.Success, + Payload = new QueryResponse + { + Count = 2, + Results = new List + { + new DocumentSearchResult { Id = 1 }, + new DocumentSearchResult { Id = 2 }, + }, + }, + }); + + var filter = new DocumentSearchFilterModel { Content = "term" }; + + Action act = () => service.GetPage(filter); + + act.Should().Throw(); + documentRepository.Verify(r => r.GetPageDeep(It.IsAny()), Times.Never); + } + [Fact] public void GetPimsDocumentTypes_ShouldThrowException_NotAuthorized() { diff --git a/source/backend/tests/dal/Repositories/DocumentRepositoryTest.cs b/source/backend/tests/dal/Repositories/DocumentRepositoryTest.cs index d2a2b0882c..c5117a1d78 100644 --- a/source/backend/tests/dal/Repositories/DocumentRepositoryTest.cs +++ b/source/backend/tests/dal/Repositories/DocumentRepositoryTest.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using FluentAssertions; +using Pims.Api.Models.Concepts.Document; using Pims.Core.Test; using Pims.Dal.Entities; using Pims.Dal.Exceptions; @@ -188,6 +189,93 @@ public void DocumentRelationshipCount_Success() } #endregion + #region GetPageDeep + [Fact] + public void GetPageDeep_Filters_By_MayanDocumentIds() + { + // Arrange + var helper = new TestHelper(); + var user = PrincipalHelper.CreateForPermission(Permissions.DocumentView); + + var context = helper.CreatePimsContext(user, true); + var timestamp = DateTime.UtcNow; + var templateType = new PimsDocumentTyp() + { + DocumentTypeId = 100, + DocumentType = "CDOGTEMP", + DocumentTypeDescription = "Template", + ConcurrencyControlNumber = 1, + AppCreateTimestamp = timestamp, + AppCreateUserid = "test", + AppCreateUserDirectory = "test", + AppLastUpdateTimestamp = timestamp, + AppLastUpdateUserid = "test", + AppLastUpdateUserDirectory = "test", + DbCreateTimestamp = timestamp, + DbCreateUserid = "test", + DbLastUpdateTimestamp = timestamp, + DbLastUpdateUserid = "test" + }; + var acquisitionFile = EntityHelper.CreateAcquisitionFile(10); + + var matchingDocument = EntityHelper.CreateDocument(id: 1); + matchingDocument.MayanId = 101; + var nonMatchingDocument = EntityHelper.CreateDocument(id: 2); + nonMatchingDocument.MayanId = 202; + + PimsAcquisitionFileDocument CreateLink(long linkId, PimsDocument document) + { + return new PimsAcquisitionFileDocument() + { + AcquisitionFileDocumentId = linkId, + AcquisitionFileId = acquisitionFile.AcquisitionFileId, + DocumentId = document.DocumentId, + AcquisitionFile = acquisitionFile, + Document = document, + ConcurrencyControlNumber = 1, + AppCreateTimestamp = timestamp, + AppCreateUserid = "test", + AppCreateUserDirectory = "test", + AppLastUpdateTimestamp = timestamp, + AppLastUpdateUserid = "test", + AppLastUpdateUserDirectory = "test", + DbCreateTimestamp = timestamp, + DbCreateUserid = "test", + DbLastUpdateTimestamp = timestamp, + DbLastUpdateUserid = "test" + }; + } + + var matchingLink = CreateLink(1000, matchingDocument); + var nonMatchingLink = CreateLink(1001, nonMatchingDocument); + matchingDocument.PimsAcquisitionFileDocuments = new List() { matchingLink }; + nonMatchingDocument.PimsAcquisitionFileDocuments = new List() { nonMatchingLink }; + acquisitionFile.PimsAcquisitionFileDocuments.Add(matchingLink); + acquisitionFile.PimsAcquisitionFileDocuments.Add(nonMatchingLink); + + context.PimsDocumentTyps.Add(templateType); + context.PimsAcquisitionFiles.Add(acquisitionFile); + context.PimsDocuments.AddRange(matchingDocument, nonMatchingDocument); + context.PimsAcquisitionFileDocuments.AddRange(matchingLink, nonMatchingLink); + context.SaveChanges(); + + var repository = helper.CreateRepository(user); + var filter = new DocumentSearchFilterModel() + { + Page = 1, + Quantity = 10, + MayanDocumentIds = new[] { matchingDocument.MayanId.Value } + }; + + // Act + var result = repository.GetPageDeep(filter); + + // Assert + result.Items.Should().HaveCount(1); + result.Items.First().DocumentId.Should().Be(matchingDocument.DocumentId); + } + #endregion + #endregion } } diff --git a/source/frontend/src/features/documents/documentGlobalSearch/DocumentSearchResults/DocumentSearchResults.tsx b/source/frontend/src/features/documents/documentGlobalSearch/DocumentSearchResults/DocumentSearchResults.tsx index 50b3bd8b7a..f4597f114b 100644 --- a/source/frontend/src/features/documents/documentGlobalSearch/DocumentSearchResults/DocumentSearchResults.tsx +++ b/source/frontend/src/features/documents/documentGlobalSearch/DocumentSearchResults/DocumentSearchResults.tsx @@ -24,6 +24,8 @@ export interface IDocumentSearchResultsProps { export const DocumentSearchResults: React.FC = ({ results, totalItems, + pageIndex, + pageSize, setPageIndex, setPageSize, onViewDetails, @@ -37,8 +39,29 @@ export const DocumentSearchResults: React.FC = ({ // This will get called when the table needs new data const updateCurrentPage = useCallback( - ({ pageIndex }: { pageIndex: number }) => setPageIndex && setPageIndex(pageIndex), - [setPageIndex], + ({ pageIndex: nextPageIndex }: { pageIndex: number }) => { + if (!setPageIndex) { + return; + } + if (pageIndex === nextPageIndex) { + return; + } + setPageIndex(nextPageIndex); + }, + [setPageIndex, pageIndex], + ); + + const handlePageSizeChange = useCallback( + (nextPageSize: number) => { + if (!setPageSize) { + return; + } + if (pageSize === nextPageSize) { + return; + } + setPageSize(nextPageSize); + }, + [setPageSize, pageSize], ); return ( @@ -50,7 +73,9 @@ export const DocumentSearchResults: React.FC = ({ data={results ?? []} noRowsMessage="No matching Documents found" onRequestData={updateCurrentPage} - onPageSizeChange={setPageSize} + onPageSizeChange={handlePageSizeChange} + pageIndex={pageIndex} + pageSize={pageSize} {...rest} > ); diff --git a/source/frontend/src/features/documents/documentGlobalSearch/list/DocumentSearchFilter/DocumentSearchFilter.tsx b/source/frontend/src/features/documents/documentGlobalSearch/list/DocumentSearchFilter/DocumentSearchFilter.tsx index 2586e13b65..95bfa6feca 100644 --- a/source/frontend/src/features/documents/documentGlobalSearch/list/DocumentSearchFilter/DocumentSearchFilter.tsx +++ b/source/frontend/src/features/documents/documentGlobalSearch/list/DocumentSearchFilter/DocumentSearchFilter.tsx @@ -60,6 +60,30 @@ export const DocumentSearchFilter: React.FC + + + + + + + + + + + + - - - - - - - diff --git a/source/frontend/src/features/documents/models/DocumentFilterModel.ts b/source/frontend/src/features/documents/models/DocumentFilterModel.ts index 2511562cd6..32777b259b 100644 --- a/source/frontend/src/features/documents/models/DocumentFilterModel.ts +++ b/source/frontend/src/features/documents/models/DocumentFilterModel.ts @@ -4,6 +4,7 @@ export class DocumentSearchFilterModel { documentTypTypeCode = ''; documentStatusTypeCode = ''; documentName = ''; + content = ''; searchBy = 'pid'; pin: string; pid: string; @@ -11,6 +12,7 @@ export class DocumentSearchFilterModel { toApi(): ApiGen_Concepts_DocumentSearchFilter { return { + content: this.content, documentName: this.documentName, documentTypTypeCode: this.documentTypTypeCode, documentStatusTypeCode: this.documentStatusTypeCode, diff --git a/source/frontend/src/hooks/useSearch.ts b/source/frontend/src/hooks/useSearch.ts index 486fd770fc..344e35685f 100644 --- a/source/frontend/src/hooks/useSearch.ts +++ b/source/frontend/src/hooks/useSearch.ts @@ -1,4 +1,4 @@ -import { AxiosResponse } from 'axios'; +import axios, { AxiosResponse } from 'axios'; import { useCallback, useMemo, useReducer, useState } from 'react'; import { useDispatch } from 'react-redux'; import { hideLoading, showLoading } from 'react-redux-loading-bar'; @@ -8,6 +8,7 @@ import { SortDirection, TableSort } from '@/components/Table/TableSort'; import useDeepCompareEffect from '@/hooks/util/useDeepCompareEffect'; import useIsMounted from '@/hooks/util/useIsMounted'; import { ApiGen_Base_Page } from '@/models/api/generated/ApiGen_Base_Page'; +import { exists } from '@/utils/utils'; import { IPaginateRequest } from './pims-api/interfaces/IPaginateRequest'; import { useFetcher } from './useFetcher'; @@ -76,6 +77,7 @@ export function useSearch( // is this component/hook mounted? const isMounted = useIsMounted(); const dispatch = useDispatch(); + const inError = exists(state.error); const setSearchOutput = useCallback( (apiResponse?: ApiGen_Base_Page, pageSize = 10) => { @@ -94,12 +96,12 @@ export function useSearch( totalItems: 0, totalPages: 0, }); - if (noResultsWarning) { + if (noResultsWarning && !inError) { toast.warn(noResultsWarning); } } }, - [noResultsWarning], + [noResultsWarning, inError], ); const pageSize = state.pageSize; @@ -119,7 +121,17 @@ export function useSearch( } catch (e) { if (isMounted()) { setSearchOutput(undefined, 0); - setState({ error: (e as Error) ?? new Error('Something went wrong. Please try again.') }); + if (axios.isAxiosError(e)) { + if (exists(e.response.data?.details)) { + setState({ error: { message: e.response.data.details, name: '' } }); + } else { + setState({ error: { message: e.message, name: '' } }); + } + } else { + setState({ + error: (e as Error) ?? new Error('Something went wrong. Please try again.'), + }); + } } } finally { setState({ loading: false }); diff --git a/source/frontend/src/models/api/generated/ApiGen_Concepts_DocumentSearchFilter.ts b/source/frontend/src/models/api/generated/ApiGen_Concepts_DocumentSearchFilter.ts index 06640b6166..0f0a684a07 100644 --- a/source/frontend/src/models/api/generated/ApiGen_Concepts_DocumentSearchFilter.ts +++ b/source/frontend/src/models/api/generated/ApiGen_Concepts_DocumentSearchFilter.ts @@ -5,6 +5,7 @@ // LINK: @backend/apimodels/Models/Concepts/Document/DocumentSearchFilterModel.cs export interface ApiGen_Concepts_DocumentSearchFilter { + content: string | null; documentTypTypeCode: string | null; documentStatusTypeCode: string | null; documentName: string | null; diff --git a/tools/mayan-edms/.mayan-env b/tools/mayan-edms/.mayan-env index 093deffa72..98f4c65c66 100644 --- a/tools/mayan-edms/.mayan-env +++ b/tools/mayan-edms/.mayan-env @@ -40,7 +40,9 @@ MAYAN_COMMON_PROJECT_TITLE='Mayan EDMS' MAYAN_DOCUMENTS_FILE_PAGE_IMAGE_CACHE_MAXIMUM_SIZE='5242880000' MAYAN_DOCUMENT_PARSING_AUTO_PARSING='true' MAYAN_OCR_AUTO_OCR='false' -MAYAN_SEARCH_DISABLE='true' +MAYAN_SEARCH_DISABLE='false' +MAYAN_SEARCH_BACKEND='mayan.apps.dynamic_search.backends.elasticsearch.ElasticSearchBackend' +MAYAN_SEARCH_BACKEND_ARGUMENTS={"client_host":"http://elasticsearch:9200","client_http_auth":["elastic","mayanespassword"]} # Gunicorn MAYAN_GUNICORN_LIMIT_REQUEST_LINE=4094 diff --git a/tools/mayan-edms/docker-compose.yml b/tools/mayan-edms/docker-compose.yml index e05017a1f4..0a6648a0f5 100644 --- a/tools/mayan-edms/docker-compose.yml +++ b/tools/mayan-edms/docker-compose.yml @@ -192,6 +192,17 @@ services: - all - mayan + worker_e: + <<: *mayan-container + command: + - run_worker + - worker_e + - "--prefetch-multiplier=1 --concurrency=10 --max-memory-per-child=1000 -E" + profiles: + - extra_worker_e + - all + - mayan + worker_custom_queue: <<: *mayan-container command: From 00e6f99aac13a1b6abdcfe32ba9b278696f34b34 Mon Sep 17 00:00:00 2001 From: devinleighsmith Date: Wed, 28 Jan 2026 15:57:39 -0800 Subject: [PATCH 2/4] test corrections. --- .../backend/api/Services/DocumentService.cs | 4 +- .../tests/api/Services/DocumentServiceTest.cs | 2 +- .../DocumentSearchFilter.test.tsx.snap | 133 ++++++++++-------- 3 files changed, 81 insertions(+), 58 deletions(-) diff --git a/source/backend/api/Services/DocumentService.cs b/source/backend/api/Services/DocumentService.cs index 8b66521f4c..14017a5650 100644 --- a/source/backend/api/Services/DocumentService.cs +++ b/source/backend/api/Services/DocumentService.cs @@ -99,7 +99,9 @@ public DocumentService( this.mapper = mapper; this.keycloakOptions = options; this.mayanOptions = mayanOptions; - configuration.Bind(MayanConfigSectionKey, _config); + var config = new MayanConfig(); + configuration?.Bind(MayanConfigSectionKey, config); + _config = config; } public static bool IsValidDocumentExtension(string fileName) diff --git a/source/backend/tests/api/Services/DocumentServiceTest.cs b/source/backend/tests/api/Services/DocumentServiceTest.cs index e03499e0fd..07e5e17234 100644 --- a/source/backend/tests/api/Services/DocumentServiceTest.cs +++ b/source/backend/tests/api/Services/DocumentServiceTest.cs @@ -72,7 +72,7 @@ private void ConfigureMayanMaxResults(int? maxResults) var mayanOptions = this._helper.GetService>>(); mayanOptions.Setup(m => m.CurrentValue).Returns(new MayanConfig { - MaxContentResults = maxResults, + MaxContentResults = maxResults ?? 2000, }); } diff --git a/source/frontend/src/features/documents/documentGlobalSearch/list/DocumentSearchFilter/__snapshots__/DocumentSearchFilter.test.tsx.snap b/source/frontend/src/features/documents/documentGlobalSearch/list/DocumentSearchFilter/__snapshots__/DocumentSearchFilter.test.tsx.snap index d01c49f099..dfbad29fd4 100644 --- a/source/frontend/src/features/documents/documentGlobalSearch/list/DocumentSearchFilter/__snapshots__/DocumentSearchFilter.test.tsx.snap +++ b/source/frontend/src/features/documents/documentGlobalSearch/list/DocumentSearchFilter/__snapshots__/DocumentSearchFilter.test.tsx.snap @@ -294,63 +294,17 @@ exports[`Document search Filter > matches snapshot 1`] = ` class="col-xl-12" >
-
-
-
- -
-
-
-
-
- -
-
+
@@ -464,6 +418,73 @@ exports[`Document search Filter > matches snapshot 1`] = ` +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
Date: Fri, 30 Jan 2026 10:27:29 -0800 Subject: [PATCH 3/4] correct document service mappings. --- source/backend/api/Services/DocumentService.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/source/backend/api/Services/DocumentService.cs b/source/backend/api/Services/DocumentService.cs index 14017a5650..8b66521f4c 100644 --- a/source/backend/api/Services/DocumentService.cs +++ b/source/backend/api/Services/DocumentService.cs @@ -99,9 +99,7 @@ public DocumentService( this.mapper = mapper; this.keycloakOptions = options; this.mayanOptions = mayanOptions; - var config = new MayanConfig(); - configuration?.Bind(MayanConfigSectionKey, config); - _config = config; + configuration.Bind(MayanConfigSectionKey, _config); } public static bool IsValidDocumentExtension(string fileName) From 8e2f4ece481fbd7ed21789e419515c580262d571 Mon Sep 17 00:00:00 2001 From: devinleighsmith Date: Tue, 3 Feb 2026 15:06:33 -0800 Subject: [PATCH 4/4] test corrections. --- source/backend/api/Services/DocumentService.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/backend/api/Services/DocumentService.cs b/source/backend/api/Services/DocumentService.cs index 8b66521f4c..14017a5650 100644 --- a/source/backend/api/Services/DocumentService.cs +++ b/source/backend/api/Services/DocumentService.cs @@ -99,7 +99,9 @@ public DocumentService( this.mapper = mapper; this.keycloakOptions = options; this.mayanOptions = mayanOptions; - configuration.Bind(MayanConfigSectionKey, _config); + var config = new MayanConfig(); + configuration?.Bind(MayanConfigSectionKey, config); + _config = config; } public static bool IsValidDocumentExtension(string fileName)