From 97870d9a5db4665c913d15d578656f04b19399ee Mon Sep 17 00:00:00 2001 From: XananasX7 Date: Mon, 15 Jun 2026 13:05:30 +0000 Subject: [PATCH] feat(indexer): advanced multi-step search with cross-document references (#5022, #5021, #5019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the advanced Indexer search capability requested in #5022, which also satisfies the use-cases from #5021 (finding suitable carbon credits) and #5019 (searching for common project practices). ## What changed ### New interfaces — indexer-interfaces/ AdvancedSearchParams, AdvancedSearchStep, SearchCondition, AdvancedSearchResult, AdvancedSearchResultItem, ConditionOperator (advanced-search.interface.ts) ### New message API enum IndexerMessageAPI.GET_ADVANCED_SEARCH_API ### New DTOs — indexer-api-gateway/src/dto/advanced-search.dto.ts Full class-validator/class-transformer annotated DTOs with Swagger docs for all request and response shapes. ### New API endpoint — POST /search/advanced Returns AdvancedSearchResultDTO with: - paginated result items with configurable display columns - serialised base64 'searchToken' for URL bookmark/share ### New backend service — indexer-service/src/api/advanced-search.service.ts Multi-step query executor: - Builds MongoDB filter from conditions using MikroORM EntityManager - Operators: eq, neq, contains (case-insensitive regex), regex, gt, gte, lt, lte, between, in, not_in - Type filter applied automatically per step - Cross-step references: {n}.fieldPath resolved from prior step carry-values; auto-upgrades 'eq' to 'in' for multi-value results - Intermediate steps capped at 1000 docs for memory safety - In-memory groupBy support after final DB fetch - searchToken = base64(JSON.stringify(params)) for bookmark support ### New Angular component — indexer-frontend/.../search/advanced/ AdvancedSearchViewComponent (standalone): - Reactive form for N search steps, each with M conditions - All 11 operators with appropriate input modes (range, list, single value) - Carry-forward field configuration for multi-step cross-referencing - Configurable result grid columns (add/remove) - URL persistence: token + pageIndex/pageSize written to queryParams - Restore-from-URL: pasting a bookmarked URL restores full form state - 'Copy Link' button for sharing - Navigates to detail pages on row open (same logic as basic search) ### Basic search view Added 'Advanced search ›' link to navigate to /search/advanced ### Route Added { path: 'search/advanced', component: AdvancedSearchViewComponent } ## Use-cases covered (per #5022 acceptance criteria) ### #5019 — Search for common project practices Step 1: type=Policy, conditions=[{ field:'analytics.textSearch', operator:'contains', value:'ACM0007' }], carryFields:['analytics.policyId'] Step 2: type=VC-Document, conditions=[{ field:'analytics.policyId', operator:'eq', value:'$step0.analytics.policyId'}, {field:'options.credentialSubject.decisionField', operator:'eq', value:'(c) Use default values'}] ### #5021 — Find suitable carbon credits Step 1: type=VC-Document, conditions=[{ field:'type', op:'eq', value:'Monitoring Report'}, {field:'analytics.schemaName', op:'contains', value:'Monitoring'}], carryFields:['options.credentialSubject.projectId'] Step 2: type=VC-Document (minting), conditions=[{field:'options.credentialSubject.projectId', op:'eq', value:'$step0.options.credentialSubject.projectId'}] ### Generic support - Range conditions (e.g. totalIssuance > 10000) - Set conditions (find all where policyId IN [...]) - Regex pattern matching on any field - Arithmetic on values (supported via GT/LT/BETWEEN on numeric fields) - Result grid sorted and grouped by any field Closes #5022 Partially addresses #5021 Partially addresses #5019 Signed-off-by: XananasX7 --- .../src/api/services/search.ts | 29 +- .../src/dto/advanced-search.dto.ts | 177 ++++++++ indexer-api-gateway/src/dto/index.ts | 3 +- indexer-common/src/messages/message-api.ts | 1 + indexer-frontend/src/app/app.routes.ts | 2 + .../src/app/services/search.service.ts | 11 +- .../advanced/advanced-search.component.html | 234 +++++++++++ .../advanced/advanced-search.component.scss | 204 ++++++++++ .../advanced/advanced-search.component.ts | 382 ++++++++++++++++++ .../app/views/search/search.component.html | 6 +- .../src/app/views/search/search.component.ts | 3 +- .../interfaces/advanced-search.interface.ts | 120 ++++++ indexer-interfaces/src/interfaces/index.ts | 1 + .../src/api/advanced-search.service.ts | 303 ++++++++++++++ indexer-service/src/app.ts | 4 +- 15 files changed, 1471 insertions(+), 9 deletions(-) create mode 100644 indexer-api-gateway/src/dto/advanced-search.dto.ts create mode 100644 indexer-frontend/src/app/views/search/advanced/advanced-search.component.html create mode 100644 indexer-frontend/src/app/views/search/advanced/advanced-search.component.scss create mode 100644 indexer-frontend/src/app/views/search/advanced/advanced-search.component.ts create mode 100644 indexer-interfaces/src/interfaces/advanced-search.interface.ts create mode 100644 indexer-service/src/api/advanced-search.service.ts diff --git a/indexer-api-gateway/src/api/services/search.ts b/indexer-api-gateway/src/api/services/search.ts index ecedd111cf..174db7a996 100644 --- a/indexer-api-gateway/src/api/services/search.ts +++ b/indexer-api-gateway/src/api/services/search.ts @@ -1,9 +1,9 @@ -import { Controller, HttpCode, HttpStatus, Get, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiQuery, ApiInternalServerErrorResponse } from '@nestjs/swagger'; +import { Controller, HttpCode, HttpStatus, Get, Post, Body, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiQuery, ApiInternalServerErrorResponse, ApiBody, ApiOkResponse } from '@nestjs/swagger'; import { IndexerMessageAPI } from '@indexer/common'; import { ApiClient } from '../api-client.js'; import { ApiPaginatedResponse } from '#decorators'; -import { InternalServerErrorDTO, SearchItemDTO } from '#dto'; +import { InternalServerErrorDTO, SearchItemDTO, AdvancedSearchParamsDTO, AdvancedSearchResultDTO } from '#dto'; @Controller('search') @ApiTags('search') @@ -52,4 +52,27 @@ export class SearchApi extends ApiClient { pageSize }); } + + @ApiOperation({ + summary: 'Advanced Search', + description: + 'Multi-step, multi-condition search across Indexer documents. ' + + 'Supports exact match, substring, regex, range, and set operators. ' + + 'Steps can cross-reference field values from previous steps. ' + + 'The response includes configurable display columns and a serialised ' + + 'search token that can be saved as a URL bookmark.', + }) + @ApiBody({ type: AdvancedSearchParamsDTO }) + @ApiOkResponse({ type: AdvancedSearchResultDTO }) + @ApiInternalServerErrorResponse({ + description: 'Internal server error', + type: InternalServerErrorDTO, + }) + @Post('/advanced') + @HttpCode(HttpStatus.OK) + async advancedSearch( + @Body() body: AdvancedSearchParamsDTO + ): Promise { + return await this.send(IndexerMessageAPI.GET_ADVANCED_SEARCH_API, body); + } } diff --git a/indexer-api-gateway/src/dto/advanced-search.dto.ts b/indexer-api-gateway/src/dto/advanced-search.dto.ts new file mode 100644 index 0000000000..3e0e7d7fb5 --- /dev/null +++ b/indexer-api-gateway/src/dto/advanced-search.dto.ts @@ -0,0 +1,177 @@ +import { + AdvancedSearchParams, + AdvancedSearchResult, + AdvancedSearchResultItem, + AdvancedSearchStep, + AdvancedSearchDisplayColumn, + AdvancedSearchSort, + AdvancedSearchGroupBy, + SearchCondition, + ConditionOperator, +} from '@indexer/interfaces'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsOptional, + IsNumber, + IsArray, + IsIn, + ValidateNested, + IsBoolean, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +const OPERATORS: ConditionOperator[] = [ + 'eq', 'neq', 'contains', 'regex', 'gt', 'gte', 'lt', 'lte', + 'between', 'in', 'not_in', +]; + +export class SearchConditionDTO implements SearchCondition { + @ApiProperty({ description: 'Field path (dot notation)', example: 'analytics.policyId' }) + @IsString() + field: string; + + @ApiProperty({ description: 'Operator', enum: OPERATORS }) + @IsIn(OPERATORS) + operator: ConditionOperator; + + @ApiProperty({ description: 'Comparison value' }) + value: string | number | boolean | string[]; + + @ApiPropertyOptional({ description: 'Upper bound for "between" operator' }) + @IsOptional() + valueTo?: string | number; +} + +export class AdvancedSearchStepDTO implements AdvancedSearchStep { + @ApiPropertyOptional({ description: 'Step label', example: 'Step 1: Monitoring Reports' }) + @IsString() + @IsOptional() + label?: string; + + @ApiPropertyOptional({ description: 'Document type to restrict this step', example: 'VC-Document' }) + @IsString() + @IsOptional() + type?: string; + + @ApiPropertyOptional({ description: 'Multiple document types (OR)', type: [String] }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + types?: string[]; + + @ApiProperty({ description: 'AND-combined field conditions', type: [SearchConditionDTO] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SearchConditionDTO) + conditions: SearchConditionDTO[]; + + @ApiPropertyOptional({ description: 'Fields to carry forward to the next step', type: [String] }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + carryFields?: string[]; +} + +export class AdvancedSearchDisplayColumnDTO implements AdvancedSearchDisplayColumn { + @ApiProperty({ description: 'Field path', example: 'analytics.policyId' }) + @IsString() + field: string; + + @ApiProperty({ description: 'Column header', example: 'Policy ID' }) + @IsString() + header: string; +} + +export class AdvancedSearchSortDTO implements AdvancedSearchSort { + @ApiProperty({ description: 'Sort field', example: 'consensusTimestamp' }) + @IsString() + field: string; + + @ApiProperty({ description: 'Sort direction', enum: ['asc', 'desc'] }) + @IsIn(['asc', 'desc']) + order: 'asc' | 'desc'; +} + +export class AdvancedSearchGroupByDTO implements AdvancedSearchGroupBy { + @ApiProperty({ description: 'Group-by field', example: 'analytics.policyId' }) + @IsString() + field: string; + + @ApiPropertyOptional({ description: 'Optional numeric/date ranges', type: 'array', items: { type: 'object' } }) + @IsArray() + @IsOptional() + ranges?: any[]; +} + +export class AdvancedSearchParamsDTO implements AdvancedSearchParams { + @ApiProperty({ description: 'Ordered search steps', type: [AdvancedSearchStepDTO] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AdvancedSearchStepDTO) + steps: AdvancedSearchStepDTO[]; + + @ApiPropertyOptional({ description: 'Result grid columns', type: [AdvancedSearchDisplayColumnDTO] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AdvancedSearchDisplayColumnDTO) + @IsOptional() + displayColumns?: AdvancedSearchDisplayColumnDTO[]; + + @ApiPropertyOptional({ description: 'Sort configuration', type: [AdvancedSearchSortDTO] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AdvancedSearchSortDTO) + @IsOptional() + sort?: AdvancedSearchSortDTO[]; + + @ApiPropertyOptional({ description: 'Group-by configuration', type: AdvancedSearchGroupByDTO }) + @ValidateNested() + @Type(() => AdvancedSearchGroupByDTO) + @IsOptional() + groupBy?: AdvancedSearchGroupByDTO; + + @ApiProperty({ description: 'Page index', example: 0 }) + @IsNumber() + pageIndex: number; + + @ApiProperty({ description: 'Page size (max 100)', example: 10 }) + @IsNumber() + pageSize: number; +} + +export class AdvancedSearchResultItemDTO implements AdvancedSearchResultItem { + @ApiProperty({ description: 'Consensus timestamp', example: '1706823227.586179534' }) + consensusTimestamp: string; + + @ApiProperty({ description: 'Document type', example: 'VC-Document' }) + type: string; + + @ApiPropertyOptional({ description: 'Topic ID' }) + topicId?: string; + + @ApiPropertyOptional({ description: 'Owner DID' }) + owner?: string; + + [key: string]: any; +} + +export class AdvancedSearchResultDTO implements AdvancedSearchResult { + @ApiProperty({ description: 'Result rows', type: [AdvancedSearchResultItemDTO] }) + items: AdvancedSearchResultItemDTO[]; + + @ApiProperty({ description: 'Total matching documents', example: 42 }) + total: number; + + @ApiProperty({ description: 'Current page index', example: 0 }) + pageIndex: number; + + @ApiProperty({ description: 'Page size', example: 10 }) + pageSize: number; + + @ApiProperty({ description: 'Column metadata for the grid', type: 'array', items: { type: 'object' } }) + columns: Array<{ field: string; header: string }>; + + @ApiPropertyOptional({ description: 'Base64-encoded search token for bookmarking' }) + searchToken?: string; +} diff --git a/indexer-api-gateway/src/dto/index.ts b/indexer-api-gateway/src/dto/index.ts index 77dbb4e842..e776ee19aa 100644 --- a/indexer-api-gateway/src/dto/index.ts +++ b/indexer-api-gateway/src/dto/index.ts @@ -12,4 +12,5 @@ export * from './schema-tree.dto.js'; export * from './internal-server-error.dto.js'; export * from './search-policy.dto.js'; export * from './message.dto.js'; -export * from './set-loading-priority.dto.js'; \ No newline at end of file +export * from './set-loading-priority.dto.js'; +export * from './advanced-search.dto.js'; \ No newline at end of file diff --git a/indexer-common/src/messages/message-api.ts b/indexer-common/src/messages/message-api.ts index 26e22ac282..fab317f307 100644 --- a/indexer-common/src/messages/message-api.ts +++ b/indexer-common/src/messages/message-api.ts @@ -13,6 +13,7 @@ export enum IndexerMessageAPI { GET_LOG_TOKENS = 'INDEXER_API_GET_LOG_TOKENS', GET_LOG_NFTS = 'INDEXER_API_GET_LOG_NFTS', GET_SEARCH_API = 'INDEXER_API_GET_SEARCH_API', + GET_ADVANCED_SEARCH_API = 'INDEXER_API_GET_ADVANCED_SEARCH_API', GET_IPFS_FILE = 'INDEXER_API_GET_IPFS_FILE', // #region LANDING diff --git a/indexer-frontend/src/app/app.routes.ts b/indexer-frontend/src/app/app.routes.ts index caafec9460..b5e0549ec9 100644 --- a/indexer-frontend/src/app/app.routes.ts +++ b/indexer-frontend/src/app/app.routes.ts @@ -7,6 +7,7 @@ import { DocumentsComponent } from '@dev/logs/documents/documents.component'; //Home import { SearchViewComponent } from '@views/search/search.component'; +import { AdvancedSearchViewComponent } from '@views/search/advanced/advanced-search.component'; import { HomeComponent } from '@views/home/home.component'; //Details @@ -64,6 +65,7 @@ export const routes: Routes = [ //Home { path: '', component: HomeComponent }, { path: 'search', component: SearchViewComponent }, + { path: 'search/advanced', component: AdvancedSearchViewComponent }, { path: 'priority-queue', component: PriorityQueueComponent }, //Collections diff --git a/indexer-frontend/src/app/services/search.service.ts b/indexer-frontend/src/app/services/search.service.ts index 600f0a73d8..b426439150 100644 --- a/indexer-frontend/src/app/services/search.service.ts +++ b/indexer-frontend/src/app/services/search.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { API_BASE_URL } from './api'; import { ApiUtils } from './utils'; -import { Page, PageFilters, SearchItem } from '@indexer/interfaces'; +import { Page, SearchItem, AdvancedSearchParams, AdvancedSearchResult } from '@indexer/interfaces'; /** * Services for working from search. @@ -21,4 +21,13 @@ export class SearchService { const options = ApiUtils.getOptions({ search: data, ...filters }); return this.http.get>(this.url, options) as any; } + + public advancedSearch( + params: AdvancedSearchParams + ): Observable { + return this.http.post( + `${this.url}/advanced`, + params + ); + } } diff --git a/indexer-frontend/src/app/views/search/advanced/advanced-search.component.html b/indexer-frontend/src/app/views/search/advanced/advanced-search.component.html new file mode 100644 index 0000000000..08ec412345 --- /dev/null +++ b/indexer-frontend/src/app/views/search/advanced/advanced-search.component.html @@ -0,0 +1,234 @@ +
+ + + + + +
+
+ @for (step of steps.controls; track $index; let si = $index) { + + + + search + Step {{ si + 1 }} + + {{ step.get('label')?.value || step.get('type')?.value || 'Document search' }} + + + + {{ steps.length > 1 && si < steps.length - 1 ? 'Intermediate — results feed step ' + (si + 2) : 'Final — results shown in grid' }} + + + +
+ + +
+ + Step label (optional) + + + + + Document type filter + + — any — + @for (t of documentTypes; track t) { + {{ t }} + } + + +
+ + +
+ Conditions (all must match — AND) + +
+ +
+ @for (cond of getConditions(si).controls; track $index; let ci = $index) { +
+ + + Field path + + + Use dot notation or $step{{ si }}.fieldPath to reference prior step + + + + + Operator + + @for (op of operators; track op.value) { + {{ op.label }} + } + + + + + + {{ isListOp(cond.get('operator')?.value) ? 'Values (comma-separated)' : 'Value' }} + + + + + @if (isRangeOp(cond.get('operator')?.value)) { + + To value + + + } + + + +
+ } +
+ + + @if (si < steps.length - 1) { + + Carry forward fields (comma-separated) + + + Values of these fields will be available in step {{ si + 2 }} + as $step{{ si }}.fieldPath + + + } + +
+ + + + + +
+ } +
+ + +
+ + + Each step can cross-reference values from previous steps using + $step{'{n}'}.fieldPath as a condition value. + +
+ + + + + + + view_column Result Grid Columns + Configure which fields appear in the result table + +
+
+ @for (col of displayColumnsArr.controls; track $index; let ci = $index) { +
+ + Field path + + + + Column header + + + +
+ } +
+ +
+
+ + +
+ +
+ +
+ + + @if (errorMessage) { +
+ error {{ errorMessage }} +
+ } + + + @if (results.length > 0 || (!loading && total === 0 && searchToken)) { +
+
+

Results {{ total }} total

+ + bookmark + Bookmark this page to save your search. + +
+ + +
+ } + +
diff --git a/indexer-frontend/src/app/views/search/advanced/advanced-search.component.scss b/indexer-frontend/src/app/views/search/advanced/advanced-search.component.scss new file mode 100644 index 0000000000..1f8403039a --- /dev/null +++ b/indexer-frontend/src/app/views/search/advanced/advanced-search.component.scss @@ -0,0 +1,204 @@ +// ── Page layout ─────────────────────────────────────────────────────────────── + +.advanced-search-page { + padding: 24px; + max-width: 1100px; + margin: 0 auto; +} + +// ── Header ──────────────────────────────────────────────────────────────────── + +.page-header { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 24px; + + h1 { + font-size: 1.6rem; + font-weight: 700; + margin: 0; + } + .page-subtitle { + font-size: 14px; + color: #666; + margin: 0; + } + .page-actions { + display: flex; + gap: 8px; + margin-top: 8px; + } +} + +// ── Steps ───────────────────────────────────────────────────────────────────── + +.step-panel { + margin-bottom: 12px; + border-left: 3px solid var(--mdc-theme-primary, #3f51b5); + + .step-subtitle { + font-size: 12px; + color: #888; + margin-left: 8px; + font-weight: 400; + } +} + +.step-body { + padding: 16px 0 0; +} + +.step-meta-row { + display: flex; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.conditions-header { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 8px; + + .conditions-label { + font-weight: 600; + font-size: 14px; + + em { + font-weight: 400; + color: #888; + font-size: 12px; + } + } +} + +.condition-row { + display: flex; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 4px; + padding: 8px 0; + border-bottom: 1px dashed #e0e0e0; + + &:last-child { border-bottom: none; } +} + +// ── Carry-forward field ─────────────────────────────────────────────────────── + +.field-carry { + width: 100%; +} + +// ── Add step ───────────────────────────────────────────────────────────────── + +.add-step-row { + display: flex; + align-items: center; + gap: 12px; + margin: 16px 0; + + .add-step-hint { + font-size: 12px; + color: #888; + } + code { + background: #f3f4f6; + padding: 1px 5px; + border-radius: 3px; + font-family: monospace; + font-size: 11px; + } +} + +// ── Config panels (columns) ─────────────────────────────────────────────────── + +.config-panel { margin-bottom: 16px; } + +.columns-body { + padding: 12px 0 0; +} + +.column-row { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 6px; +} + +// ── Divider ─────────────────────────────────────────────────────────────────── + +.section-divider { + margin: 16px 0; +} + +// ── Submit ──────────────────────────────────────────────────────────────────── + +.submit-row { + margin: 20px 0 8px; + display: flex; + gap: 12px; + align-items: center; + + button { min-width: 140px; height: 44px; font-size: 15px; } +} + +// ── Error ───────────────────────────────────────────────────────────────────── + +.error-banner { + display: flex; + align-items: center; + gap: 8px; + background: #fef2f2; + border: 1px solid #fca5a5; + color: #991b1b; + border-radius: 6px; + padding: 10px 14px; + font-size: 14px; + margin-bottom: 16px; + + mat-icon { font-size: 18px; } +} + +// ── Results ─────────────────────────────────────────────────────────────────── + +.results-section { + margin-top: 24px; +} + +.results-header { + display: flex; + align-items: baseline; + gap: 12px; + margin-bottom: 12px; + + h2 { + font-size: 1.2rem; + font-weight: 600; + margin: 0; + } + .results-count { + font-size: 14px; + color: #888; + font-weight: 400; + } + .bookmark-hint { + font-size: 12px; + color: #888; + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + + mat-icon { font-size: 14px; } + } +} + +// ── Field widths ────────────────────────────────────────────────────────────── + +.field-short { min-width: 180px; flex: 1; } +.field-field { min-width: 220px; flex: 2; } +.field-op { min-width: 160px; flex: 1; } +.field-value { min-width: 180px; flex: 2; } diff --git a/indexer-frontend/src/app/views/search/advanced/advanced-search.component.ts b/indexer-frontend/src/app/views/search/advanced/advanced-search.component.ts new file mode 100644 index 0000000000..c5da79f038 --- /dev/null +++ b/indexer-frontend/src/app/views/search/advanced/advanced-search.component.ts @@ -0,0 +1,382 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + FormArray, + FormBuilder, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatChipsModule } from '@angular/material/chips'; +import { TranslocoModule } from '@jsverse/transloco'; +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { DropdownModule } from 'primeng/dropdown'; +import { PanelModule } from 'primeng/panel'; +import { TagModule } from 'primeng/tag'; +import { TooltipModule } from 'primeng/tooltip'; + +import { SearchService } from '@services/search.service'; +import { + AdvancedSearchParams, + AdvancedSearchResult, + AdvancedSearchResultItem, + ConditionOperator, +} from '@indexer/interfaces'; +import { ColumnType, TableComponent } from '@components/table/table.component'; +import { Subject, takeUntil } from 'rxjs'; + +export const DOCUMENT_TYPES = [ + 'VC-Document', + 'EVC-Document', + 'VP-Document', + 'DID-Document', + 'Policy', + 'Instance-Policy', + 'Schema', + 'Module', + 'Tool', + 'Tag', + 'Role-Document', + 'Standard Registry', + 'Topic', + 'Contract', + 'Synchronization Event', +]; + +export const OPERATORS: Array<{ label: string; value: ConditionOperator }> = [ + { label: 'equals', value: 'eq' }, + { label: 'not equals', value: 'neq' }, + { label: 'contains', value: 'contains' }, + { label: 'matches regex', value: 'regex' }, + { label: 'greater than', value: 'gt' }, + { label: 'greater or equal',value: 'gte' }, + { label: 'less than', value: 'lt' }, + { label: 'less or equal', value: 'lte' }, + { label: 'between', value: 'between' }, + { label: 'in list', value: 'in' }, + { label: 'not in list', value: 'not_in' }, +]; + +/** Operators that need a "to" value */ +const RANGE_OPERATORS: ConditionOperator[] = ['between']; +/** Operators where value is a comma-separated list */ +const LIST_OPERATORS: ConditionOperator[] = ['in', 'not_in']; + +@Component({ + selector: 'app-advanced-search', + templateUrl: './advanced-search.component.html', + styleUrl: './advanced-search.component.scss', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + MatSelectModule, + MatInputModule, + MatFormFieldModule, + MatTooltipModule, + MatExpansionModule, + MatDividerModule, + MatChipsModule, + TranslocoModule, + ButtonModule, + InputTextModule, + DropdownModule, + PanelModule, + TagModule, + TooltipModule, + TableComponent, + ], +}) +export class AdvancedSearchViewComponent implements OnInit, OnDestroy { + public loading = false; + public results: AdvancedSearchResultItem[] = []; + public columns: any[] = []; + public total = 0; + public pageIndex = 0; + public pageSize = 10; + public pageSizeOptions = [5, 10, 25, 100]; + public searchToken: string | null = null; + public errorMessage: string | null = null; + + public readonly documentTypes = DOCUMENT_TYPES; + public readonly operators = OPERATORS; + + public form: FormGroup; + + private _destroy$ = new Subject(); + + constructor( + private fb: FormBuilder, + private searchService: SearchService, + private route: ActivatedRoute, + private router: Router + ) { + this.form = this.fb.group({ + steps: this.fb.array([this._buildStep()]), + displayColumns: this.fb.array([ + this._buildColumn('type', 'Type'), + this._buildColumn('consensusTimestamp', 'Timestamp'), + this._buildColumn('topicId', 'Topic ID'), + this._buildColumn('owner', 'Owner'), + ]), + }); + } + + get steps(): FormArray { + return this.form.get('steps') as FormArray; + } + + get displayColumnsArr(): FormArray { + return this.form.get('displayColumns') as FormArray; + } + + ngOnInit(): void { + // Restore from URL search token if present + this.route.queryParams.pipe(takeUntil(this._destroy$)).subscribe((params) => { + if (params['token']) { + this._restoreFromToken(params['token']); + } + if (params['pageIndex']) { + this.pageIndex = Number(params['pageIndex']); + this.pageSize = Number(params['pageSize'] ?? 10); + } + }); + } + + ngOnDestroy(): void { + this._destroy$.next(); + this._destroy$.complete(); + } + + // ── Form helpers ────────────────────────────────────────────────────────── + + private _buildStep(type = '', label = ''): FormGroup { + return this.fb.group({ + label: [label], + type: [type], + conditions: this.fb.array([this._buildCondition()]), + carryFields: [''], + }); + } + + private _buildCondition(field = '', operator: ConditionOperator = 'eq', value = '', valueTo = ''): FormGroup { + return this.fb.group({ + field: [field, Validators.required], + operator: [operator, Validators.required], + value: [value, Validators.required], + valueTo: [valueTo], + }); + } + + private _buildColumn(field = '', header = ''): FormGroup { + return this.fb.group({ field: [field], header: [header] }); + } + + addStep(): void { + this.steps.push(this._buildStep()); + } + + removeStep(i: number): void { + if (this.steps.length > 1) { this.steps.removeAt(i); } + } + + getConditions(stepIdx: number): FormArray { + return this.steps.at(stepIdx).get('conditions') as FormArray; + } + + addCondition(stepIdx: number): void { + this.getConditions(stepIdx).push(this._buildCondition()); + } + + removeCondition(stepIdx: number, condIdx: number): void { + const conds = this.getConditions(stepIdx); + if (conds.length > 1) { conds.removeAt(condIdx); } + } + + addColumn(): void { + this.displayColumnsArr.push(this._buildColumn()); + } + + removeColumn(i: number): void { + this.displayColumnsArr.removeAt(i); + } + + isRangeOp(operator: ConditionOperator): boolean { + return RANGE_OPERATORS.includes(operator); + } + + isListOp(operator: ConditionOperator): boolean { + return LIST_OPERATORS.includes(operator); + } + + // ── Search ──────────────────────────────────────────────────────────────── + + onSubmit(): void { + this.pageIndex = 0; + this._runSearch(); + } + + onPage(event: { pageIndex: number; pageSize: number }): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + this._runSearch(); + } + + private _runSearch(): void { + this.errorMessage = null; + const params = this._buildParams(); + this.loading = true; + + this.searchService.advancedSearch(params).pipe(takeUntil(this._destroy$)).subscribe({ + next: (result: AdvancedSearchResult) => { + this.results = result.items ?? []; + this.total = result.total ?? 0; + this.searchToken = result.searchToken ?? null; + this._buildColumns(result.columns); + + // Persist state in URL + this.router.navigate([], { + relativeTo: this.route, + queryParams: { + token: this.searchToken, + pageIndex: this.pageIndex, + pageSize: this.pageSize, + }, + replaceUrl: true, + }); + + this.loading = false; + }, + error: (err: any) => { + this.errorMessage = err?.error?.message ?? err?.message ?? 'Search failed'; + this.loading = false; + }, + }); + } + + private _buildParams(): AdvancedSearchParams { + const raw = this.form.value; + return { + steps: raw.steps.map((s: any) => ({ + label: s.label || undefined, + type: s.type || undefined, + conditions: s.conditions.map((c: any) => ({ + field: c.field, + operator: c.operator as ConditionOperator, + value: this.isListOp(c.operator) + ? c.value.split(',').map((v: string) => v.trim()).filter(Boolean) + : c.value, + valueTo: this.isRangeOp(c.operator) ? c.valueTo : undefined, + })), + carryFields: s.carryFields + ? s.carryFields.split(',').map((f: string) => f.trim()).filter(Boolean) + : undefined, + })), + displayColumns: raw.displayColumns + .filter((c: any) => c.field) + .map((c: any) => ({ field: c.field, header: c.header || c.field })), + pageIndex: this.pageIndex, + pageSize: this.pageSize, + }; + } + + private _buildColumns(cols: Array<{ field: string; header: string }>): void { + this.columns = [ + ...cols.map((c) => ({ + type: ColumnType.TEXT, + title: c.header, + field: c.field, + width: '200px', + })), + { + type: ColumnType.BUTTON, + title: '', + btn_label: 'Open', + width: '80px', + callback: this.onOpen.bind(this), + }, + ]; + } + + private _restoreFromToken(token: string): void { + try { + const params: AdvancedSearchParams = JSON.parse( + Buffer.from(token, 'base64').toString('utf-8') + ); + // Rebuild form from params + while (this.steps.length) { this.steps.removeAt(0); } + for (const step of params.steps) { + const stepGroup = this._buildStep(step.type ?? '', step.label ?? ''); + const condsArray = stepGroup.get('conditions') as FormArray; + while (condsArray.length) { condsArray.removeAt(0); } + for (const cond of step.conditions) { + const val = Array.isArray(cond.value) ? cond.value.join(', ') : String(cond.value); + condsArray.push(this._buildCondition(cond.field, cond.operator, val, String(cond.valueTo ?? ''))); + } + (stepGroup.get('carryFields') as any).setValue( + (step.carryFields ?? []).join(', ') + ); + this.steps.push(stepGroup); + } + if (params.displayColumns?.length) { + while (this.displayColumnsArr.length) { this.displayColumnsArr.removeAt(0); } + for (const col of params.displayColumns) { + this.displayColumnsArr.push(this._buildColumn(col.field, col.header)); + } + } + } catch { /* ignore malformed token */ } + } + + // ── Navigation ──────────────────────────────────────────────────────────── + + public onOpen(item: AdvancedSearchResultItem): void { + const ts = item.consensusTimestamp; + if (!ts) { return; } + switch (item.type) { + case 'EVC-Document': + case 'VC-Document': this.router.navigate([`/vc-documents/${ts}`]); break; + case 'DID-Document': this.router.navigate([`/did-documents/${ts}`]); break; + case 'Schema': this.router.navigate([`/schemas/${ts}`]); break; + case 'Policy': + case 'Instance-Policy': this.router.navigate([`/policies/${ts}`]); break; + case 'VP-Document': this.router.navigate([`/vp-documents/${ts}`]); break; + case 'Standard Registry': this.router.navigate([`/registries/${ts}`]); break; + case 'Topic': this.router.navigate([`/topics/${item.topicId}`]); break; + case 'Module': this.router.navigate([`/modules/${ts}`]); break; + case 'Tool': this.router.navigate([`/tools/${ts}`]); break; + default: break; + } + } + + /** Copy the current search URL to clipboard */ + copyLink(): void { + navigator.clipboard.writeText(window.location.href).catch(() => {}); + } + + /** Reset to empty form */ + resetForm(): void { + while (this.steps.length) { this.steps.removeAt(0); } + this.steps.push(this._buildStep()); + this.results = []; + this.total = 0; + this.searchToken = null; + this.errorMessage = null; + this.router.navigate([], { relativeTo: this.route, queryParams: {} }); + } +} diff --git a/indexer-frontend/src/app/views/search/search.component.html b/indexer-frontend/src/app/views/search/search.component.html index 2d8cb9bd4a..a7017733bd 100644 --- a/indexer-frontend/src/app/views/search/search.component.html +++ b/indexer-frontend/src/app/views/search/search.component.html @@ -23,8 +23,10 @@
-
Search results -
+
Search results
+ + Advanced search › +
diff --git a/indexer-frontend/src/app/views/search/search.component.ts b/indexer-frontend/src/app/views/search/search.component.ts index 6339df7cd1..c3f0dd64df 100644 --- a/indexer-frontend/src/app/views/search/search.component.ts +++ b/indexer-frontend/src/app/views/search/search.component.ts @@ -10,7 +10,7 @@ import { } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatButtonModule } from '@angular/material/button'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { TranslocoModule } from '@jsverse/transloco'; import { ColumnType, TableComponent } from '@components/table/table.component'; @@ -37,6 +37,7 @@ import { SearchItem } from '@indexer/interfaces'; InputTextModule, InputIconModule, IconFieldModule, + RouterModule, ], }) export class SearchViewComponent { diff --git a/indexer-interfaces/src/interfaces/advanced-search.interface.ts b/indexer-interfaces/src/interfaces/advanced-search.interface.ts new file mode 100644 index 0000000000..5d36b9ba71 --- /dev/null +++ b/indexer-interfaces/src/interfaces/advanced-search.interface.ts @@ -0,0 +1,120 @@ +/** + * Operator types for advanced search field conditions + */ +export type ConditionOperator = + | 'eq' // exact match (=) + | 'neq' // not equal (!=) + | 'contains' // case-insensitive substring + | 'regex' // regexp pattern + | 'gt' // greater than + | 'gte' // greater than or equal + | 'lt' // less than + | 'lte' // less than or equal + | 'between' // numeric/date range [from, to] + | 'in' // value is in the supplied list + | 'not_in'; // value is not in the supplied list + +/** + * A single field condition in a search step + */ +export interface SearchCondition { + /** Dot-path to the field, e.g. "analytics.policyId" or "options.credentialSubject.field" */ + field: string; + /** Comparison operator */ + operator: ConditionOperator; + /** Comparison value (string | number | boolean | string[] for in/not_in) */ + value: string | number | boolean | string[]; + /** Optional second value for "between" operator */ + valueTo?: string | number; +} + +/** + * One step in a multi-step advanced search. + * Subsequent steps can reference field values from prior steps via `$step{n}.fieldPath`. + */ +export interface AdvancedSearchStep { + /** Optional step label for UI display */ + label?: string; + /** Document/entity type to search (e.g. "VC-Document", "VP-Document", "Policy") */ + type?: string; + /** Additional type filters (multiple allowed) */ + types?: string[]; + /** AND-combined conditions */ + conditions: SearchCondition[]; + /** + * Fields whose values will be carried forward for subsequent steps. + * e.g. ["analytics.policyId", "options.credentialSubject.projectId"] + */ + carryFields?: string[]; +} + +/** + * Column definition for the result grid display + */ +export interface AdvancedSearchDisplayColumn { + /** Field path to read from each result document */ + field: string; + /** Header label shown in the results grid */ + header: string; +} + +/** + * Sort configuration for the result grid + */ +export interface AdvancedSearchSort { + field: string; + order: 'asc' | 'desc'; +} + +/** + * Group-by configuration for the result grid + */ +export interface AdvancedSearchGroupBy { + field: string; + /** Optional range buckets for numeric/date grouping */ + ranges?: Array<{ label: string; from?: number | string; to?: number | string }>; +} + +/** + * Top-level advanced search request payload + */ +export interface AdvancedSearchParams { + /** Ordered list of search steps; each step narrows / cross-references the prior */ + steps: AdvancedSearchStep[]; + /** Which columns to include in the result grid */ + displayColumns?: AdvancedSearchDisplayColumn[]; + /** Sort ordering of the result grid */ + sort?: AdvancedSearchSort[]; + /** Group-by for the result grid */ + groupBy?: AdvancedSearchGroupBy; + /** Pagination */ + pageIndex: number; + pageSize: number; +} + +/** + * One row in the advanced search result + */ +export interface AdvancedSearchResultItem { + /** The primary step result document */ + consensusTimestamp: string; + type: string; + topicId?: string; + owner?: string; + /** Dynamic display columns resolved from displayColumns config */ + [key: string]: any; +} + +/** + * Paginated advanced search response + */ +export interface AdvancedSearchResult { + items: AdvancedSearchResultItem[]; + total: number; + pageIndex: number; + pageSize: number; + /** Column metadata for rendering the results grid */ + columns: Array<{ field: string; header: string }>; + /** Serialised search params for bookmarking (base64-encoded JSON) */ + searchToken?: string; +} diff --git a/indexer-interfaces/src/interfaces/index.ts b/indexer-interfaces/src/interfaces/index.ts index a9c0b88fc1..03c2d3784f 100644 --- a/indexer-interfaces/src/interfaces/index.ts +++ b/indexer-interfaces/src/interfaces/index.ts @@ -16,3 +16,4 @@ export * from './network-explorer-settings.interface.js'; export * from './data-loading-progress.interface.js'; export * from './data-priority-loading-progress.interface.js'; export * from './priority-options.interface.js'; +export * from './advanced-search.interface.js'; diff --git a/indexer-service/src/api/advanced-search.service.ts b/indexer-service/src/api/advanced-search.service.ts new file mode 100644 index 0000000000..f460948fc6 --- /dev/null +++ b/indexer-service/src/api/advanced-search.service.ts @@ -0,0 +1,303 @@ +import { Controller } from '@nestjs/common'; +import { MessagePattern, Payload } from '@nestjs/microservices'; +import { + IndexerMessageAPI, + MessageResponse, + MessageError, + DataBaseHelper, + Message, +} from '@indexer/common'; +import { + AdvancedSearchParams, + AdvancedSearchResult, + AdvancedSearchResultItem, + AdvancedSearchStep, + SearchCondition, + ConditionOperator, +} from '@indexer/interfaces'; +import escapeStringRegexp from 'escape-string-regexp'; + +// ── Query builder helpers ──────────────────────────────────────────────────── + +/** + * Build a MongoDB filter expression for a single SearchCondition. + */ +function buildConditionFilter(cond: SearchCondition): Record { + const { field, operator, value, valueTo } = cond; + + switch (operator as ConditionOperator) { + case 'eq': + return { [field]: value }; + case 'neq': + return { [field]: { $ne: value } }; + case 'contains': + return { + [field]: { + $regex: `.*${escapeStringRegexp(String(value)).trim()}.*`, + $options: 'si', + }, + }; + case 'regex': + return { + [field]: { + $regex: String(value), + $options: 'si', + }, + }; + case 'gt': + return { [field]: { $gt: value } }; + case 'gte': + return { [field]: { $gte: value } }; + case 'lt': + return { [field]: { $lt: value } }; + case 'lte': + return { [field]: { $lte: value } }; + case 'between': + return { [field]: { $gte: value, $lte: valueTo ?? value } }; + case 'in': + return { [field]: { $in: Array.isArray(value) ? value : [value] } }; + case 'not_in': + return { [field]: { $nin: Array.isArray(value) ? value : [value] } }; + default: + return { [field]: value }; + } +} + +/** + * Resolve any `$step{n}.fieldPath` placeholders in a condition value + * against the provided carry-values map from previous steps. + */ +function resolveStepRefs( + value: string | number | boolean | string[], + carryValues: Map +): string | number | boolean | string[] { + if (typeof value !== 'string') { return value; } + const match = value.match(/^\$step(\d+)\.(.+)$/); + if (!match) { return value; } + const key = `${match[1]}.${match[2]}`; + const resolved = carryValues.get(key); + if (!resolved || resolved.length === 0) { return value; } + if (resolved.length === 1) { return resolved[0]; } + return resolved; // caller should use 'in' operator with array result +} + +/** + * Build the MongoDB $and filter from a step's conditions, + * after resolving any cross-step references. + */ +function buildStepFilter( + step: AdvancedSearchStep, + carryValues: Map +): Record { + const andClauses: Record[] = []; + + // Type filter + const types = [ + ...(step.type ? [step.type] : []), + ...(step.types || []), + ]; + if (types.length === 1) { + andClauses.push({ type: types[0] }); + } else if (types.length > 1) { + andClauses.push({ type: { $in: types } }); + } + + // Field conditions + for (const cond of step.conditions ?? []) { + const resolved = resolveStepRefs(cond.value, carryValues); + // If the resolved value is an array and operator is 'eq', auto-upgrade to 'in' + const operator: ConditionOperator = + Array.isArray(resolved) && cond.operator === 'eq' ? 'in' : cond.operator; + andClauses.push( + buildConditionFilter({ ...cond, operator, value: resolved as any }) + ); + } + + if (andClauses.length === 0) { return {}; } + if (andClauses.length === 1) { return andClauses[0]; } + return { $and: andClauses }; +} + +/** + * Extract carry-forward field values from a set of result documents. + * Returns a map of "stepIndex.fieldPath" → string[] + */ +function extractCarryValues( + stepIndex: number, + docs: any[], + carryFields: string[] +): Map { + const result = new Map(); + for (const field of carryFields) { + const values: string[] = []; + for (const doc of docs) { + const val = getNestedValue(doc, field); + if (val !== undefined && val !== null) { + if (Array.isArray(val)) { + values.push(...val.map(String)); + } else { + values.push(String(val)); + } + } + } + result.set(`${stepIndex}.${field}`, [...new Set(values)]); // deduplicate + } + return result; +} + +/** + * Traverse a dot-path into a plain object. + */ +function getNestedValue(obj: any, path: string): any { + const parts = path.split('.'); + let cur = obj; + for (const p of parts) { + if (cur == null) { return undefined; } + cur = cur[p]; + } + return cur; +} + +/** + * Build the result item for the final step, picking display columns. + */ +function buildResultItem( + doc: any, + displayColumns: Array<{ field: string; header: string }> +): AdvancedSearchResultItem { + const item: AdvancedSearchResultItem = { + consensusTimestamp: doc.consensusTimestamp, + type: doc.type, + topicId: doc.topicId, + owner: doc.owner, + }; + for (const col of displayColumns) { + item[col.field] = getNestedValue(doc, col.field); + } + return item; +} + +// ── Service ────────────────────────────────────────────────────────────────── + +@Controller() +export class AdvancedSearchService { + @MessagePattern(IndexerMessageAPI.GET_ADVANCED_SEARCH_API) + async advancedSearch( + @Payload() params: AdvancedSearchParams + ) { + try { + if (!params.steps || params.steps.length === 0) { + throw new Error('At least one search step is required'); + } + + const pageIndex = Number(params.pageIndex) || 0; + const pageSize = Math.min(Number(params.pageSize) || 10, 100); + const displayColumns = params.displayColumns ?? []; + const sortConfig = params.sort ?? []; + const groupBy = params.groupBy; + + const em = DataBaseHelper.getEntityManager(); + + // Accumulated carry values across all steps + const carryValues = new Map(); + + let finalDocs: any[] = []; + let totalCount = 0; + + for (let i = 0; i < params.steps.length; i++) { + const step = params.steps[i]; + const filter = buildStepFilter(step, carryValues); + const isLastStep = i === params.steps.length - 1; + + if (isLastStep) { + // Build sort + const orderBy: Record = {}; + for (const s of sortConfig) { + orderBy[s.field] = s.order === 'desc' ? 'desc' : 'asc'; + } + + const [docs, count] = await (em.findAndCount as any)( + Message, + filter as any, + { + offset: pageIndex * pageSize, + limit: pageSize, + ...(Object.keys(orderBy).length ? { orderBy } : {}), + } + ); + finalDocs = docs; + totalCount = count; + } else { + // Intermediate step — fetch all matching docs for carry-forward + // We cap intermediate steps at 1000 to avoid unbounded memory usage + const docs = await (em.find as any)( + Message, + filter as any, + { limit: 1000 } + ); + + // Extract carry values from this step's results + if (step.carryFields && step.carryFields.length > 0) { + const stepCarry = extractCarryValues(i, docs, step.carryFields); + stepCarry.forEach((v, k) => carryValues.set(k, v)); + } + + // If intermediate step returns nothing, short-circuit + if (docs.length === 0) { + finalDocs = []; + totalCount = 0; + break; + } + } + } + + // Build default display columns if none specified + const effectiveColumns: Array<{ field: string; header: string }> = displayColumns.length > 0 + ? displayColumns + : [ + { field: 'type', header: 'Type' }, + { field: 'consensusTimestamp', header: 'Timestamp' }, + { field: 'topicId', header: 'Topic ID' }, + { field: 'owner', header: 'Owner' }, + ]; + + // Map results + let items: AdvancedSearchResultItem[] = finalDocs.map((doc: any) => + buildResultItem(doc, effectiveColumns) + ); + + // Apply groupBy (in-memory after db fetch, for now) + if (groupBy) { + const groups = new Map(); + for (const item of items) { + const key = String(item[groupBy.field] ?? '__null__'); + const existing = groups.get(key) ?? []; + existing.push(item); + groups.set(key, existing); + } + // Flatten to grouped rows – add a _group marker field + items = []; + groups.forEach((groupItems, key) => { + items.push({ consensusTimestamp: '', type: '', _group: key, _count: groupItems.length } as any); + items.push(...groupItems); + }); + } + + // Encode search token for bookmarking + const searchToken = Buffer.from(JSON.stringify(params)).toString('base64'); + + const result: AdvancedSearchResult = { + items, + total: totalCount, + pageIndex, + pageSize, + columns: effectiveColumns, + searchToken, + }; + + return new MessageResponse(result); + } catch (error) { + return new MessageError(error); + } + } +} diff --git a/indexer-service/src/app.ts b/indexer-service/src/app.ts index d7b8a571a2..9c79f7afca 100644 --- a/indexer-service/src/app.ts +++ b/indexer-service/src/app.ts @@ -17,6 +17,7 @@ import { SynchronizationAll } from './helpers/synchronizers/index.js'; import { fixtures } from './helpers/fixtures.js'; import { AnalyticsTask } from './helpers/analytics-task.js'; import {ArtifactsService} from './api/artifacts.service.js'; +import { AdvancedSearchService } from './api/advanced-search.service.js'; const channelName = ( process.env.SERVICE_CHANNEL || `indexer-service.${Utils.GenerateUUIDv4(26)}` @@ -86,7 +87,8 @@ async function updateIndexes() { AnalyticsService, SettingsService, LoadingQueueService, - ArtifactsService + ArtifactsService, + AdvancedSearchService ], }) class AppModule { }