From 042d75bd1805b53a47ca06caeb40af63c4522066 Mon Sep 17 00:00:00 2001 From: Koda Reef Date: Sun, 22 Mar 2026 15:46:53 +0000 Subject: [PATCH] feat: 5021 5019 Add Mint Token search and Project Practices search to Indexer Implements two new Indexer search capabilities: Mint Token Search (#5021): - Paginated VP document search with token + policy enrichment - Filters: tokenId, policyId, amount range, date range, keywords - Response includes policyId, policyDescription, geography at top level - Filter options API for dropdowns Project Practices Search (#5019): - Paginated VC document search with policy enrichment - Filters: policyId, schemaName (substring), schemaId, topicId, keywords - Filter options API for schema names and policies Full stack: message API, service layer, API gateway with Swagger docs, Angular frontend components with grid, filters, sorting, i18n. Closes #5021 Closes #5019 Signed-off-by: Koda Reef --- .../src/api/services/entities.ts | 213 ++++++++++ indexer-common/src/messages/message-api.ts | 11 + indexer-frontend/src/app/app.routes.ts | 6 + .../app/components/header/header.component.ts | 8 + .../src/app/services/entities.service.ts | 35 ++ .../mint-tokens/mint-tokens.component.html | 33 ++ .../mint-tokens/mint-tokens.component.scss | 7 + .../mint-tokens/mint-tokens.component.ts | 205 ++++++++++ .../project-practices.component.html | 33 ++ .../project-practices.component.scss | 7 + .../project-practices.component.ts | 176 ++++++++ indexer-frontend/src/assets/i18n/en.json | 19 +- indexer-service/src/api/entities.service.ts | 376 ++++++++++++++++++ indexer-service/src/api/filters.service.ts | 1 + 14 files changed, 1127 insertions(+), 3 deletions(-) create mode 100644 indexer-frontend/src/app/views/collections/mint-tokens/mint-tokens.component.html create mode 100644 indexer-frontend/src/app/views/collections/mint-tokens/mint-tokens.component.scss create mode 100644 indexer-frontend/src/app/views/collections/mint-tokens/mint-tokens.component.ts create mode 100644 indexer-frontend/src/app/views/collections/project-practices/project-practices.component.html create mode 100644 indexer-frontend/src/app/views/collections/project-practices/project-practices.component.scss create mode 100644 indexer-frontend/src/app/views/collections/project-practices/project-practices.component.ts diff --git a/indexer-api-gateway/src/api/services/entities.ts b/indexer-api-gateway/src/api/services/entities.ts index 3322fc6abe..c75ecf3304 100644 --- a/indexer-api-gateway/src/api/services/entities.ts +++ b/indexer-api-gateway/src/api/services/entities.ts @@ -2117,5 +2117,218 @@ export class EntityApi extends ApiClient { ); } //#endregion + + //#region MINT TOKEN SEARCH (Issue #5021) + @ApiOperation({ + summary: 'Get mint token documents', + description: 'Returns a list of minting VP documents for searching issued carbon credits. Supports filtering by token, policy, amount range, and date range.', + }) + @ApiPaginatedRequest + @ApiPaginatedResponse('Mint token documents', VPGridDTO) + @ApiQuery({ + name: 'analytics.tokenId', + description: 'Token identifier to filter by', + example: '0.0.1960', + required: false, + }) + @ApiQuery({ + name: 'analytics.policyId', + description: 'Policy (methodology) identifier to filter by', + example: '1706823227.586179534', + required: false, + }) + @ApiQuery({ + name: 'minAmount', + description: 'Minimum token amount', + example: '1000', + required: false, + }) + @ApiQuery({ + name: 'maxAmount', + description: 'Maximum token amount', + example: '100000', + required: false, + }) + @ApiQuery({ + name: 'dateFrom', + description: 'Start date filter (consensus timestamp)', + example: '1706823227.000000000', + required: false, + }) + @ApiQuery({ + name: 'dateTo', + description: 'End date filter (consensus timestamp)', + example: '1709501627.000000000', + required: false, + }) + @ApiQuery({ + name: 'keywords', + description: 'Keywords to search (JSON array)', + examples: { + geography: { + description: 'Search by geography', + value: '["Brazil"]', + }, + }, + required: false, + }) + @Get('/mint-tokens') + @HttpCode(HttpStatus.OK) + async getMintTokenDocuments( + @Query('pageIndex') pageIndex?: number, + @Query('pageSize') pageSize?: number, + @Query('orderField') orderField?: string, + @Query('orderDir') orderDir?: string, + @Query('keywords') keywords?: string, + @Query('analytics.tokenId') tokenId?: string, + @Query('analytics.policyId') policyId?: string, + @Query('minAmount') minAmount?: string, + @Query('maxAmount') maxAmount?: string, + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, + ) { + return await this.send(IndexerMessageAPI.GET_MINT_TOKEN_DOCUMENTS, { + pageIndex, + pageSize, + orderField, + orderDir, + keywords, + 'analytics.tokenId': tokenId, + 'analytics.policyId': policyId, + minAmount, + maxAmount, + dateFrom, + dateTo, + }); + } + + @ApiOperation({ + summary: 'Get mint token filters', + description: 'Returns available filter options (policies, tokens) for the mint token search', + }) + @ApiOkResponse({ + description: 'Mint token filter options', + }) + @ApiInternalServerErrorResponse({ + description: 'Internal server error', + type: InternalServerErrorDTO, + }) + @Get('/mint-tokens/filters') + @HttpCode(HttpStatus.OK) + async getMintTokenFilters() { + return await this.send(IndexerMessageAPI.GET_MINT_TOKEN_FILTERS, {}); + } + + @ApiOperation({ + summary: 'Get mint token document details', + description: 'Returns details of a specific minting VP document including token and policy information', + }) + @ApiOkResponse({ + description: 'Mint token document details', + type: VPDetailsDTO, + }) + @ApiInternalServerErrorResponse({ + description: 'Internal server error', + type: InternalServerErrorDTO, + }) + @Get('/mint-tokens/:messageId') + @ApiParam({ + name: 'messageId', + description: 'Message identifier (consensus timestamp)', + example: '1706823227.586179534', + }) + @HttpCode(HttpStatus.OK) + async getMintTokenDocument(@Param('messageId') messageId: string) { + return await this.send(IndexerMessageAPI.GET_MINT_TOKEN_DOCUMENT, { + messageId, + }); + } + //#endregion + + //#region PROJECT PRACTICES SEARCH (Issue #5019) + @ApiOperation({ + summary: 'Search project practices', + description: 'Search for VC documents (e.g., Monitoring Reports) under the same policy to compare common project practices across methodologies.', + }) + @ApiPaginatedRequest + @ApiPaginatedResponse('Project practice documents', VCGridDTO) + @ApiQuery({ + name: 'analytics.policyId', + description: 'Policy (methodology) identifier to filter by', + example: '1706823227.586179534', + required: false, + }) + @ApiQuery({ + name: 'analytics.schemaId', + description: 'Schema identifier to filter by', + example: '1706823227.586179534', + required: false, + }) + @ApiQuery({ + name: 'analytics.schemaName', + description: 'Schema name to filter by (e.g., "Monitoring Report")', + example: 'Monitoring Report', + required: false, + }) + @ApiQuery({ + name: 'keywords', + description: 'Keywords to search within document fields (JSON array). Use to find specific methodological decisions.', + examples: { + methodology_choice: { + description: 'Search for a specific methodology choice', + value: '["Use default values"]', + }, + }, + required: false, + }) + @ApiQuery({ + name: 'topicId', + description: 'Topic identifier', + example: '0.0.1960', + required: false, + }) + @Get('/project-practices') + @HttpCode(HttpStatus.OK) + async getProjectPractices( + @Query('pageIndex') pageIndex?: number, + @Query('pageSize') pageSize?: number, + @Query('orderField') orderField?: string, + @Query('orderDir') orderDir?: string, + @Query('keywords') keywords?: string, + @Query('analytics.policyId') policyId?: string, + @Query('analytics.schemaId') schemaId?: string, + @Query('analytics.schemaName') schemaName?: string, + @Query('topicId') topicId?: string, + ) { + return await this.send(IndexerMessageAPI.GET_PROJECT_PRACTICES, { + pageIndex, + pageSize, + orderField, + orderDir, + keywords, + 'analytics.policyId': policyId, + 'analytics.schemaId': schemaId, + 'analytics.schemaName': schemaName, + topicId, + }); + } + + @ApiOperation({ + summary: 'Get project practices filter options', + description: 'Returns available filter options (schema names, policies) for the project practices search', + }) + @ApiOkResponse({ + description: 'Project practices filter options', + }) + @ApiInternalServerErrorResponse({ + description: 'Internal server error', + type: InternalServerErrorDTO, + }) + @Get('/project-practices/filters') + @HttpCode(HttpStatus.OK) + async getProjectPracticesFilters() { + return await this.send(IndexerMessageAPI.GET_PROJECT_PRACTICES_FILTERS, {}); + } + //#endregion //#endregion } diff --git a/indexer-common/src/messages/message-api.ts b/indexer-common/src/messages/message-api.ts index 26e22ac282..d1f8d1baf8 100644 --- a/indexer-common/src/messages/message-api.ts +++ b/indexer-common/src/messages/message-api.ts @@ -115,6 +115,17 @@ export enum IndexerMessageAPI { GET_COMPARE_ORIGINAL_POLICY = "INDEXER_API_GET_COMPARE_ORIGINAL_POLICY", GET_DERIVATIONS = "INDEXER_API_GET_DERIVATIONS", + // #region TOKEN SEARCH (Issue #5021) + GET_MINT_TOKEN_DOCUMENTS = 'INDEXER_API_GET_MINT_TOKEN_DOCUMENTS', + GET_MINT_TOKEN_DOCUMENT = 'INDEXER_API_GET_MINT_TOKEN_DOCUMENT', + GET_MINT_TOKEN_FILTERS = 'INDEXER_API_GET_MINT_TOKEN_FILTERS', + // #endregion + + // #region PROJECT PRACTICES SEARCH (Issue #5019) + GET_PROJECT_PRACTICES = 'INDEXER_API_GET_PROJECT_PRACTICES', + GET_PROJECT_PRACTICES_FILTERS = 'INDEXER_API_GET_PROJECT_PRACTICES_FILTERS', + // #endregion + } export const ARTIFACT_CHUNK_BYTES = Number(process.env.ARTIFACT_CHUNK_BYTES ?? 128 * 1024); diff --git a/indexer-frontend/src/app/app.routes.ts b/indexer-frontend/src/app/app.routes.ts index caafec9460..9f87922dcf 100644 --- a/indexer-frontend/src/app/app.routes.ts +++ b/indexer-frontend/src/app/app.routes.ts @@ -52,6 +52,8 @@ import { FormulaDetailsComponent } from '@views/details/formula-details/formula- import { PriorityQueueComponent } from '@views/priority-queue/priority-queue.component'; import { SchemasPackagesComponent } from '@views/collections/schemas-packages/schemas-packages.component'; import { SchemasPackageDetailsComponent } from '@views/details/schemas-packages-details/schemas-packages-details.component'; +import { MintTokensComponent } from '@views/collections/mint-tokens/mint-tokens.component'; +import { ProjectPracticesComponent } from '@views/collections/project-practices/project-practices.component'; export const routes: Routes = [ // _DEV @@ -87,6 +89,8 @@ export const routes: Routes = [ { path: 'statistic-documents', component: StatisticDocumentsComponent }, { path: 'formulas', component: FormulasComponent }, { path: 'schemas-packages', component: SchemasPackagesComponent }, + { path: 'mint-tokens', component: MintTokensComponent }, + { path: 'project-practices', component: ProjectPracticesComponent }, //Details { path: 'registries/:id', component: RegistryDetailsComponent }, @@ -109,4 +113,6 @@ export const routes: Routes = [ { path: 'statistic-documents/:id', component: VcDocumentDetailsComponent }, { path: 'formulas/:id', component: FormulaDetailsComponent }, { path: 'schemas-packages/:id', component: SchemasPackageDetailsComponent }, + { path: 'mint-tokens/:id', component: VpDocumentDetailsComponent }, + { path: 'project-practices/:id', component: VcDocumentDetailsComponent }, ]; diff --git a/indexer-frontend/src/app/components/header/header.component.ts b/indexer-frontend/src/app/components/header/header.component.ts index f0b85f6e8e..4ce7e90bcc 100644 --- a/indexer-frontend/src/app/components/header/header.component.ts +++ b/indexer-frontend/src/app/components/header/header.component.ts @@ -97,6 +97,14 @@ export class HeaderComponent { label: 'header.formulas', routerLink: '/formulas', }, + { + label: 'header.mint_tokens', + routerLink: '/mint-tokens', + }, + { + label: 'header.project_practices', + routerLink: '/project-practices', + }, ]; public documentsMenu: MenuItem[] = [ diff --git a/indexer-frontend/src/app/services/entities.service.ts b/indexer-frontend/src/app/services/entities.service.ts index 2007db3f6c..13c511b740 100644 --- a/indexer-frontend/src/app/services/entities.service.ts +++ b/indexer-frontend/src/app/services/entities.service.ts @@ -283,6 +283,41 @@ export class EntitiesService { ) as any; } //#endregion + //#region MINT TOKENS + public getMintTokenDocuments(filters: PageFilters): Observable> { + const entity = 'mint-tokens'; + const options = ApiUtils.getOptions(filters); + return this.http.get>(`${this.url}/${entity}`, options) as any; + } + + public getMintTokenDocument(messageId: string): Observable { + const entity = 'mint-tokens'; + return this.http.get( + `${this.url}/${entity}/${messageId}` + ) as any; + } + + public getMintTokenFilters(): Observable { + const entity = 'mint-tokens'; + return this.http.get( + `${this.url}/${entity}/filters` + ) as any; + } + //#endregion + //#region PROJECT PRACTICES + public getProjectPractices(filters: PageFilters): Observable> { + const entity = 'project-practices'; + const options = ApiUtils.getOptions(filters); + return this.http.get>(`${this.url}/${entity}`, options) as any; + } + + public getProjectPracticesFilters(): Observable { + const entity = 'project-practices'; + return this.http.get( + `${this.url}/${entity}/filters` + ) as any; + } + //#endregion //#region VCS public getVcDocuments(filters: PageFilters): Observable> { const entity = 'vc-documents'; diff --git a/indexer-frontend/src/app/views/collections/mint-tokens/mint-tokens.component.html b/indexer-frontend/src/app/views/collections/mint-tokens/mint-tokens.component.html new file mode 100644 index 0000000000..184dd6baf0 --- /dev/null +++ b/indexer-frontend/src/app/views/collections/mint-tokens/mint-tokens.component.html @@ -0,0 +1,33 @@ +
+
+

{{ 'header.mint_tokens' | transloco }}

+
+ +
+ +
+ @for (filter of filters; track $index) { + @if (filter.type === 'input') { + + {{ filter.label | transloco }} + + + } + } +
+
+
+
+
+
diff --git a/indexer-frontend/src/app/views/collections/mint-tokens/mint-tokens.component.scss b/indexer-frontend/src/app/views/collections/mint-tokens/mint-tokens.component.scss new file mode 100644 index 0000000000..d93ba81fdc --- /dev/null +++ b/indexer-frontend/src/app/views/collections/mint-tokens/mint-tokens.component.scss @@ -0,0 +1,7 @@ +.table-body { + .mat-column-tokenAmount { + width: 150px; + min-width: 150px; + max-width: 150px; + } +} diff --git a/indexer-frontend/src/app/views/collections/mint-tokens/mint-tokens.component.ts b/indexer-frontend/src/app/views/collections/mint-tokens/mint-tokens.component.ts new file mode 100644 index 0000000000..4220a4ab7c --- /dev/null +++ b/indexer-frontend/src/app/views/collections/mint-tokens/mint-tokens.component.ts @@ -0,0 +1,205 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatSortModule } from '@angular/material/sort'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatTableModule } from '@angular/material/table'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { LoadingComponent } from '@components/loading/loading.component'; +import { BaseGridComponent, Filter } from '../base-grid/base-grid.component'; +import { TranslocoModule } from '@jsverse/transloco'; +import { SelectFilterComponent } from '@components/select-filter/select-filter.component'; +import { EntitiesService } from '@services/entities.service'; +import { ColumnType, TableComponent } from '@components/table/table.component'; +import { InputGroupModule } from 'primeng/inputgroup'; +import { InputGroupAddonModule } from 'primeng/inputgroupaddon'; +import { ChipsModule } from 'primeng/chips'; +import { HederaType } from '@components/hedera-explorer/hedera-explorer.component'; + +@Component({ + selector: 'mint-tokens', + templateUrl: './mint-tokens.component.html', + styleUrls: [ + '../base-grid/base-grid.component.scss', + './mint-tokens.component.scss', + ], + standalone: true, + imports: [ + CommonModule, + MatPaginatorModule, + MatTableModule, + MatSortModule, + MatFormFieldModule, + MatSelectModule, + MatInputModule, + FormsModule, + MatButtonModule, + LoadingComponent, + TranslocoModule, + ReactiveFormsModule, + SelectFilterComponent, + TableComponent, + InputGroupModule, + InputGroupAddonModule, + ChipsModule + ] +}) +export class MintTokensComponent extends BaseGridComponent { + columns: any[] = [ + { + type: ColumnType.BUTTON, + title: 'grid.open', + btn_label: 'grid.open', + width: '100px', + callback: this.onOpen.bind(this), + }, + { + type: ColumnType.TEXT, + field: 'analytics.tokenAmount', + title: 'grid.amount', + width: '150px', + sort: true, + }, + { + type: ColumnType.TEXT, + field: 'token.name', + title: 'grid.token_name', + width: '180px', + }, + { + type: ColumnType.TEXT, + field: 'token.symbol', + title: 'grid.token_symbol', + width: '100px', + }, + { + type: ColumnType.TEXT, + field: 'analytics.tokenId', + title: 'grid.token_id', + width: '150px', + link: { + field: 'analytics.tokenId', + url: '/tokens', + }, + }, + { + type: ColumnType.TEXT, + field: 'policy.name', + title: 'grid.policy', + width: '200px', + }, + { + type: ColumnType.TEXT, + field: 'geography', + title: 'grid.geography', + width: '150px', + }, + { + type: ColumnType.CHIP, + field: 'status', + title: 'grid.status', + width: '100px', + sort: true, + }, + { + type: ColumnType.HEDERA, + field: 'consensusTimestamp', + title: 'grid.consensus_timestamp', + width: '250px', + sort: true, + hederaType: HederaType.TRANSACTION, + }, + { + type: ColumnType.TEXT, + field: 'consensusTimestamp', + title: 'grid.date', + width: '200px', + sort: true, + formatValue: (value: any) => { + const fixedTimestamp = Math.floor(value * 1000); + return new Date(fixedTimestamp).toLocaleString(); + } + }, + ]; + + constructor( + private entitiesService: EntitiesService, + route: ActivatedRoute, + router: Router + ) { + super(route, router); + + this.orderField = 'consensusTimestamp'; + this.orderDir = 'desc'; + + this.filters.push( + new Filter({ + label: 'grid.filter.token_id', + type: 'input', + field: 'analytics.tokenId', + }), + new Filter({ + label: 'grid.filter.policy_id', + type: 'input', + field: 'analytics.policyId', + }), + new Filter({ + label: 'grid.filter.min_amount', + type: 'input', + field: 'minAmount', + }), + new Filter({ + label: 'grid.filter.max_amount', + type: 'input', + field: 'maxAmount', + }), + new Filter({ + label: 'grid.filter.date_from', + type: 'input', + field: 'dateFrom', + }), + new Filter({ + label: 'grid.filter.date_to', + type: 'input', + field: 'dateTo', + }), + ); + } + + protected loadData(): void { + const filters = this.getFilters(); + this.loadingData = true; + this.entitiesService.getMintTokenDocuments(filters).subscribe({ + next: (result) => { + this.setResult(result); + setTimeout(() => { + this.loadingData = false; + }, 500); + }, + error: ({ message }) => { + this.loadingData = false; + console.error(message); + } + }); + } + + protected loadFilters(): void { + this.loadingFilters = true; + this.entitiesService.getMintTokenFilters().subscribe({ + next: (result) => { + this.setFilters(result); + setTimeout(() => { + this.loadingFilters = false; + }, 500); + }, + error: ({ message }) => { + this.loadingFilters = false; + console.error(message); + } + }); + } +} diff --git a/indexer-frontend/src/app/views/collections/project-practices/project-practices.component.html b/indexer-frontend/src/app/views/collections/project-practices/project-practices.component.html new file mode 100644 index 0000000000..c824cdc882 --- /dev/null +++ b/indexer-frontend/src/app/views/collections/project-practices/project-practices.component.html @@ -0,0 +1,33 @@ +
+
+

{{ 'header.project_practices' | transloco }}

+
+ +
+ +
+ @for (filter of filters; track $index) { + @if (filter.type === 'input') { + + {{ filter.label | transloco }} + + + } + } +
+
+
+
+
+
diff --git a/indexer-frontend/src/app/views/collections/project-practices/project-practices.component.scss b/indexer-frontend/src/app/views/collections/project-practices/project-practices.component.scss new file mode 100644 index 0000000000..e0aacc8e20 --- /dev/null +++ b/indexer-frontend/src/app/views/collections/project-practices/project-practices.component.scss @@ -0,0 +1,7 @@ +.table-body { + .mat-column-schemaName { + width: 200px; + min-width: 200px; + max-width: 200px; + } +} diff --git a/indexer-frontend/src/app/views/collections/project-practices/project-practices.component.ts b/indexer-frontend/src/app/views/collections/project-practices/project-practices.component.ts new file mode 100644 index 0000000000..b44cea6841 --- /dev/null +++ b/indexer-frontend/src/app/views/collections/project-practices/project-practices.component.ts @@ -0,0 +1,176 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatSortModule } from '@angular/material/sort'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatTableModule } from '@angular/material/table'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { LoadingComponent } from '@components/loading/loading.component'; +import { BaseGridComponent, Filter } from '../base-grid/base-grid.component'; +import { TranslocoModule } from '@jsverse/transloco'; +import { SelectFilterComponent } from '@components/select-filter/select-filter.component'; +import { EntitiesService } from '@services/entities.service'; +import { ColumnType, TableComponent } from '@components/table/table.component'; +import { InputGroupModule } from 'primeng/inputgroup'; +import { InputGroupAddonModule } from 'primeng/inputgroupaddon'; +import { ChipsModule } from 'primeng/chips'; +import { HederaType } from '@components/hedera-explorer/hedera-explorer.component'; + +@Component({ + selector: 'project-practices', + templateUrl: './project-practices.component.html', + styleUrls: [ + '../base-grid/base-grid.component.scss', + './project-practices.component.scss', + ], + standalone: true, + imports: [ + CommonModule, + MatPaginatorModule, + MatTableModule, + MatSortModule, + MatFormFieldModule, + MatSelectModule, + MatInputModule, + FormsModule, + MatButtonModule, + LoadingComponent, + TranslocoModule, + ReactiveFormsModule, + SelectFilterComponent, + TableComponent, + InputGroupModule, + InputGroupAddonModule, + ChipsModule + ] +}) +export class ProjectPracticesComponent extends BaseGridComponent { + columns: any[] = [ + { + type: ColumnType.BUTTON, + title: 'grid.open', + btn_label: 'grid.open', + width: '100px', + callback: this.onOpen.bind(this), + }, + { + type: ColumnType.TEXT, + field: 'analytics.schemaName', + title: 'grid.schema', + width: '200px', + }, + { + type: ColumnType.TEXT, + field: 'policy.name', + title: 'grid.policy', + width: '200px', + }, + { + type: ColumnType.CHIP, + field: 'status', + title: 'grid.status', + width: '100px', + sort: true, + }, + { + type: ColumnType.HEDERA, + field: 'consensusTimestamp', + title: 'grid.consensus_timestamp', + width: '250px', + sort: true, + hederaType: HederaType.TRANSACTION, + }, + { + type: ColumnType.TEXT, + field: 'topicId', + title: 'grid.topic_id', + width: '150px', + link: { + field: 'topicId', + url: '/topics', + }, + }, + { + type: ColumnType.TEXT, + field: 'consensusTimestamp', + title: 'grid.date', + width: '200px', + sort: true, + formatValue: (value: any) => { + const fixedTimestamp = Math.floor(value * 1000); + return new Date(fixedTimestamp).toLocaleString(); + } + }, + ]; + + constructor( + private entitiesService: EntitiesService, + route: ActivatedRoute, + router: Router + ) { + super(route, router); + + this.orderField = 'consensusTimestamp'; + this.orderDir = 'desc'; + + this.filters.push( + new Filter({ + label: 'grid.filter.policy_id', + type: 'input', + field: 'analytics.policyId', + }), + new Filter({ + label: 'grid.filter.schema_name', + type: 'input', + field: 'analytics.schemaName', + }), + new Filter({ + label: 'grid.filter.schema_id', + type: 'input', + field: 'analytics.schemaId', + }), + new Filter({ + label: 'grid.filter.topic_id', + type: 'input', + field: 'topicId', + }), + ); + } + + protected loadData(): void { + const filters = this.getFilters(); + this.loadingData = true; + this.entitiesService.getProjectPractices(filters).subscribe({ + next: (result) => { + this.setResult(result); + setTimeout(() => { + this.loadingData = false; + }, 500); + }, + error: ({ message }) => { + this.loadingData = false; + console.error(message); + } + }); + } + + protected loadFilters(): void { + this.loadingFilters = true; + this.entitiesService.getProjectPracticesFilters().subscribe({ + next: (result) => { + this.setFilters(result); + setTimeout(() => { + this.loadingFilters = false; + }, 500); + }, + error: ({ message }) => { + this.loadingFilters = false; + console.error(message); + } + }); + } +} diff --git a/indexer-frontend/src/assets/i18n/en.json b/indexer-frontend/src/assets/i18n/en.json index bdb7a2a5b2..3dcc731380 100644 --- a/indexer-frontend/src/assets/i18n/en.json +++ b/indexer-frontend/src/assets/i18n/en.json @@ -47,6 +47,8 @@ "statistics": "Statistics", "labels": "Labels", "formulas": "Formulas", + "mint_tokens": "Mint Tokens", + "project_practices": "Project Practices", "loading_progress": "Loading data...", "left": "left" }, @@ -111,6 +113,9 @@ "entity_id": "Entity Id", "entity_type": "Entity Type", "version": "Version", + "amount": "Amount", + "token_name": "Token Name", + "token_symbol": "Token Symbol", "filter": { "policy": "Policy", "schema": "Schema", @@ -122,7 +127,13 @@ "schema_id": "Schema Id", "relationship": "Relationship", "target": "Target", - "token_id": "Token Id" + "token_id": "Token Id", + "min_amount": "Min Amount", + "max_amount": "Max Amount", + "date_from": "Date From", + "date_to": "Date To", + "schema_name": "Schema Name", + "keyword_practices": "Search within document fields (e.g., methodology choices)" }, "paginator": { "items_per_page": "Items per page", @@ -131,7 +142,9 @@ "empty": { "title": "Nothing matches your search", "description": "Please change your search criteria and try again" - } + }, + "geography": "Geography", + "policy_description": "Policy Description" }, "details": { "overview": "Overview", @@ -426,4 +439,4 @@ "try_unpacked": "Unpack", "document_version": "Version" } -} \ No newline at end of file +} diff --git a/indexer-service/src/api/entities.service.ts b/indexer-service/src/api/entities.service.ts index a8739fe44d..fe862da32d 100644 --- a/indexer-service/src/api/entities.service.ts +++ b/indexer-service/src/api/entities.service.ts @@ -2696,4 +2696,380 @@ export class EntityService { } } //#endregion + + //#region MINT TOKEN SEARCH (Issue #5021) + @MessagePattern(IndexerMessageAPI.GET_MINT_TOKEN_DOCUMENTS) + async getMintTokenDocuments( + @Payload() msg: PageFilters + ): Promise>> { + try { + const { + minAmount, + maxAmount, + dateFrom, + dateTo, + ...params + } = msg; + const options = parsePageParams(params); + const filters = parsePageFilters(params, new Set([ + 'analytics.tokenId', + 'analytics.policyId', + ])); + filters.type = MessageType.VP_DOCUMENT; + filters['analytics.tokenId'] = filters['analytics.tokenId'] || { $exists: true, $ne: null }; + + if (minAmount || maxAmount) { + const amountFilter: any = {}; + if (minAmount) { + amountFilter.$gte = Number(minAmount); + } + if (maxAmount) { + amountFilter.$lte = Number(maxAmount); + } + filters['analytics.tokenAmount'] = amountFilter; + } + + if (dateFrom || dateTo) { + const dateFilter: any = {}; + if (dateFrom) { + dateFilter.$gte = dateFrom; + } + if (dateTo) { + dateFilter.$lte = dateTo; + } + filters.consensusTimestamp = dateFilter; + } + + const em = DataBaseHelper.getEntityManager(); + const [rows, count] = (await em.findAndCount( + Message, + filters, + options + )) as [VP[], number]; + + // Enrich with token metadata + const tokenIds = new Set(); + for (const row of rows) { + if (row.analytics?.tokenId) { + tokenIds.add(row.analytics.tokenId); + } + } + + const tokenMap = new Map(); + if (tokenIds.size > 0) { + const tokens = await em.find(TokenCache, { + tokenId: { $in: [...tokenIds] }, + }); + for (const token of tokens) { + tokenMap.set(token.tokenId, token); + } + } + + // Enrich with policy metadata + const policyIds = new Set(); + for (const row of rows) { + if (row.analytics?.policyId) { + policyIds.add(row.analytics.policyId); + } + } + + const policyMap = new Map(); + if (policyIds.size > 0) { + const policies = await em.find(Message, { + type: MessageType.INSTANCE_POLICY, + consensusTimestamp: { $in: [...policyIds] }, + } as any, { + fields: ['consensusTimestamp', 'options'], + }); + for (const policy of policies) { + policyMap.set(policy.consensusTimestamp, policy); + } + } + + const items = rows.map((row) => { + const token = row.analytics?.tokenId + ? tokenMap.get(row.analytics.tokenId) + : null; + const policy = row.analytics?.policyId + ? policyMap.get(row.analytics.policyId) + : null; + return { + ...row, + // Explicit top-level fields per maintainer requirements + policyId: row.analytics?.policyId || null, + policyDescription: policy?.options?.description || null, + geography: (row.analytics as any)?.geography || null, + token: token ? { + tokenId: token.tokenId, + name: token.name, + symbol: token.symbol, + type: token.type, + treasury: token.treasury, + totalSupply: token.totalSupply, + } : null, + policy: policy ? { + name: policy.options?.name || policy.consensusTimestamp, + description: policy.options?.description || null, + } : null, + }; + }); + + const result = { + items, + pageIndex: options.offset / options.limit, + pageSize: options.limit, + total: count, + order: options.orderBy, + }; + return new MessageResponse>(result); + } catch (error) { + return new MessageError(error, getErrorCode(error.code)); + } + } + + @MessagePattern(IndexerMessageAPI.GET_MINT_TOKEN_DOCUMENT) + async getMintTokenDocument( + @Payload() msg: { messageId: string } + ): Promise> { + try { + const { messageId } = msg; + const em = DataBaseHelper.getEntityManager(); + let item = await em.findOne(Message, { + consensusTimestamp: messageId, + type: MessageType.VP_DOCUMENT, + 'analytics.tokenId': { $exists: true, $ne: null }, + } as any); + const row = await em.findOne(MessageCache, { + consensusTimestamp: messageId, + }); + + if (!item) { + return new MessageResponse({ + id: messageId, + row, + }); + } + + item = await loadDocuments(item, true); + + let token = null; + if (item.analytics?.tokenId) { + token = await em.findOne(TokenCache, { + tokenId: item.analytics.tokenId, + }); + } + + let policyMessage = null; + if (item.analytics?.policyId) { + policyMessage = await em.findOne(Message, { + type: MessageType.INSTANCE_POLICY, + consensusTimestamp: item.analytics.policyId, + } as any); + } + + return new MessageResponse({ + id: messageId, + uuid: item.uuid, + item, + row, + token: token ? { + tokenId: token.tokenId, + name: token.name, + symbol: token.symbol, + type: token.type, + treasury: token.treasury, + totalSupply: token.totalSupply, + } : null, + policy: policyMessage ? { + consensusTimestamp: policyMessage.consensusTimestamp, + options: policyMessage.options, + } : null, + }); + } catch (error) { + return new MessageError(error, getErrorCode(error.code)); + } + } + + @MessagePattern(IndexerMessageAPI.GET_MINT_TOKEN_FILTERS) + async getMintTokenFilters(): Promise> { + try { + const em = DataBaseHelper.getEntityManager(); + + const policyIds = await em.find(Message, { + type: MessageType.VP_DOCUMENT, + 'analytics.tokenId': { $exists: true, $ne: null }, + 'analytics.policyId': { $exists: true, $ne: null }, + } as any, { + fields: ['analytics'], + }); + + const uniquePolicyIds = [...new Set( + policyIds + .map((m: any) => m.analytics?.policyId) + .filter(Boolean) + )]; + + const policies = await em.find(Message, { + type: MessageType.INSTANCE_POLICY, + consensusTimestamp: { $in: uniquePolicyIds }, + } as any, { + fields: ['consensusTimestamp', 'options'], + }); + + const policyOptions = policies.map((p: any) => ({ + id: p.consensusTimestamp, + name: p.options?.name || p.consensusTimestamp, + })); + + const tokenIds = [...new Set( + policyIds + .map((m: any) => m.analytics?.tokenId) + .filter(Boolean) + )]; + + const tokens = await em.find(TokenCache, { + tokenId: { $in: tokenIds }, + }); + + const tokenOptions = tokens.map((t: any) => ({ + id: t.tokenId, + name: t.name || t.tokenId, + symbol: t.symbol, + })); + + return new MessageResponse({ + policies: policyOptions, + tokens: tokenOptions, + }); + } catch (error) { + return new MessageError(error, getErrorCode(error.code)); + } + } + //#endregion + + //#region PROJECT PRACTICES SEARCH (Issue #5019) + @MessagePattern(IndexerMessageAPI.GET_PROJECT_PRACTICES) + async getProjectPractices( + @Payload() msg: PageFilters + ): Promise>> { + try { + const { 'analytics.schemaName': schemaName, ...params } = msg as any; + const options = parsePageParams(params); + const filters = parsePageFilters(params, new Set([ + 'analytics.policyId', + 'analytics.schemaId', + ])); + filters.type = MessageType.VC_DOCUMENT; + filters.$or = [ + { 'options.initId': { $exists: false } }, + { 'options.initId': null }, + { 'options.initId': undefined }, + { 'options.initId': '' }, + ]; + + if (schemaName) { + filters['analytics.schemaName'] = createRegex(schemaName); + } + + const em = DataBaseHelper.getEntityManager(); + const [rows, count] = (await em.findAndCount( + Message, + filters, + options + )) as [VC[], number]; + + const policyIds = new Set(); + for (const row of rows) { + if (row.analytics?.policyId) { + policyIds.add(row.analytics.policyId); + } + } + + const policyMap = new Map(); + if (policyIds.size > 0) { + const policies = await em.find(Message, { + type: MessageType.INSTANCE_POLICY, + consensusTimestamp: { $in: [...policyIds] }, + } as any, { + fields: ['consensusTimestamp', 'options'], + }); + for (const policy of policies) { + policyMap.set(policy.consensusTimestamp, policy); + } + } + + const items = rows.map((item) => { + const policy = item.analytics?.policyId + ? policyMap.get(item.analytics.policyId) + : null; + return { + ...item, + analytics: item.analytics ? { + ...item.analytics, + schemaName: item.analytics.schemaName, + } : undefined, + policy: policy ? { + name: policy.options?.name || policy.consensusTimestamp, + } : null, + }; + }); + + const result = { + items, + pageIndex: options.offset / options.limit, + pageSize: options.limit, + total: count, + order: options.orderBy, + }; + return new MessageResponse>(result); + } catch (error) { + return new MessageError(error, getErrorCode(error.code)); + } + } + + @MessagePattern(IndexerMessageAPI.GET_PROJECT_PRACTICES_FILTERS) + async getProjectPracticesFilters(): Promise> { + try { + const em = DataBaseHelper.getEntityManager(); + + const schemaNames = await em.find(Message, { + type: MessageType.VC_DOCUMENT, + 'analytics.schemaName': { $exists: true, $ne: null }, + } as any, { + fields: ['analytics'], + }); + + const uniqueSchemaNames = [...new Set( + schemaNames + .map((m: any) => m.analytics?.schemaName) + .filter(Boolean) + )].sort(); + + const policyIds = [...new Set( + schemaNames + .map((m: any) => m.analytics?.policyId) + .filter(Boolean) + )]; + + const policies = await em.find(Message, { + type: MessageType.INSTANCE_POLICY, + consensusTimestamp: { $in: policyIds }, + } as any, { + fields: ['consensusTimestamp', 'options'], + }); + + const policyOptions = policies.map((p: any) => ({ + id: p.consensusTimestamp, + name: p.options?.name || p.consensusTimestamp, + })); + + return new MessageResponse({ + schemaNames: uniqueSchemaNames, + policies: policyOptions, + }); + } catch (error) { + return new MessageError(error, getErrorCode(error.code)); + } + } + //#endregion } diff --git a/indexer-service/src/api/filters.service.ts b/indexer-service/src/api/filters.service.ts index 509503bc5b..3a5e238e96 100644 --- a/indexer-service/src/api/filters.service.ts +++ b/indexer-service/src/api/filters.service.ts @@ -25,4 +25,5 @@ export class FiltersService { return new MessageError(error); } } + }