From 7add2a6e629b5bdc19c55a09096ab71b130dcf54 Mon Sep 17 00:00:00 2001 From: Queenode Date: Mon, 24 Aug 2026 16:15:51 +0100 Subject: [PATCH] feat: Implement Credential Search and Filtering Closes #42 --- .../credential-search-analytics.entity.ts | 22 +++++ ...redential-search-analytics.service.spec.ts | 37 +++++++ .../credential-search-analytics.service.ts | 36 +++++++ .../credential-search.controller.ts | 35 +++++++ .../credential-search.service.spec.ts | 75 ++++++++++++++ .../credentials/credential-search.service.ts | 99 +++++++++++++++++++ backend/src/credentials/credentials.module.ts | 11 ++- 7 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 backend/src/credentials/credential-search-analytics.entity.ts create mode 100644 backend/src/credentials/credential-search-analytics.service.spec.ts create mode 100644 backend/src/credentials/credential-search-analytics.service.ts create mode 100644 backend/src/credentials/credential-search.controller.ts create mode 100644 backend/src/credentials/credential-search.service.spec.ts create mode 100644 backend/src/credentials/credential-search.service.ts diff --git a/backend/src/credentials/credential-search-analytics.entity.ts b/backend/src/credentials/credential-search-analytics.entity.ts new file mode 100644 index 00000000..f2fc5282 --- /dev/null +++ b/backend/src/credentials/credential-search-analytics.entity.ts @@ -0,0 +1,22 @@ +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm'; + +@Entity('credential_search_analytics') +export class CredentialSearchAnalytics { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ nullable: true }) + query: string; + + @Column({ type: 'json', nullable: true }) + filters: Record; + + @Column({ type: 'int', default: 0 }) + resultCount: number; + + @Column({ type: 'float', default: 0 }) + executionTimeMs: number; + + @CreateDateColumn() + timestamp: Date; +} diff --git a/backend/src/credentials/credential-search-analytics.service.spec.ts b/backend/src/credentials/credential-search-analytics.service.spec.ts new file mode 100644 index 00000000..07f67e02 --- /dev/null +++ b/backend/src/credentials/credential-search-analytics.service.spec.ts @@ -0,0 +1,37 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { CredentialSearchAnalyticsService } from './credential-search-analytics.service'; +import { CredentialSearchAnalytics } from './credential-search-analytics.entity'; + +describe('CredentialSearchAnalyticsService', () => { + let service: any; + + const mockRepository = { + create: jest.fn().mockImplementation((dto) => dto), + save: jest.fn().mockImplementation((event) => Promise.resolve({ id: 'some-id', ...event })), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CredentialSearchAnalyticsService, + { + provide: getRepositoryToken(CredentialSearchAnalytics), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(CredentialSearchAnalyticsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should log a search event', async () => { + await service.logSearch('test query', { status: 'active' }, 5, 120); + expect(mockRepository.create).toHaveBeenCalled(); + expect(mockRepository.save).toHaveBeenCalled(); + }); +}); diff --git a/backend/src/credentials/credential-search-analytics.service.ts b/backend/src/credentials/credential-search-analytics.service.ts new file mode 100644 index 00000000..46d3977d --- /dev/null +++ b/backend/src/credentials/credential-search-analytics.service.ts @@ -0,0 +1,36 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CredentialSearchAnalytics } from './credential-search-analytics.entity'; + +@Injectable() +export class CredentialSearchAnalyticsService { + private readonly logger = new Logger(CredentialSearchAnalyticsService.name); + + constructor( + @InjectRepository(CredentialSearchAnalytics) + private readonly analyticsRepository: Repository, + ) {} + + async logSearch( + query: string, + filters: Record, + resultCount: number, + executionTimeMs: number, + ): Promise { + try { + const logEntry = this.analyticsRepository.create({ + query, + filters, + resultCount, + executionTimeMs, + }); + // Fire and forget so we don't block the request + this.analyticsRepository.save(logEntry).catch((err) => { + this.logger.error(`Failed to save search analytics: ${err.message}`, err.stack); + }); + } catch (error) { + this.logger.error(`Error constructing search analytics log: ${error.message}`, error.stack); + } + } +} diff --git a/backend/src/credentials/credential-search.controller.ts b/backend/src/credentials/credential-search.controller.ts new file mode 100644 index 00000000..b49dd7ff --- /dev/null +++ b/backend/src/credentials/credential-search.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { CredentialSearchService, SearchFilters } from './credential-search.service'; + +@Controller('credentials/search') +export class CredentialSearchController { + constructor(private readonly searchService: CredentialSearchService) {} + + @Get() + async searchCredentials( + @Query('q') query: string, + @Query('page') page: string = '1', + @Query('limit') limit: string = '20', + @Query('status') status?: string, + @Query('type') type?: string, + @Query('issuer') issuer?: string, + @Query('holder') holder?: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + // Add additional queries by letting NestJS pass the rest as generic query object if needed + // For now we extract the well known ones. + ) { + const filters: SearchFilters = {}; + if (status) filters.status = status; + if (type) filters.type = type; + if (issuer) filters.issuer = issuer; + if (holder) filters.holder = holder; + if (startDate) filters.startDate = new Date(startDate); + if (endDate) filters.endDate = new Date(endDate); + + const parsedPage = parseInt(page, 10) || 1; + const parsedLimit = parseInt(limit, 10) || 20; + + return await this.searchService.search(query, filters, parsedPage, parsedLimit); + } +} diff --git a/backend/src/credentials/credential-search.service.spec.ts b/backend/src/credentials/credential-search.service.spec.ts new file mode 100644 index 00000000..4146c9d3 --- /dev/null +++ b/backend/src/credentials/credential-search.service.spec.ts @@ -0,0 +1,75 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { CredentialSearchService } from './credential-search.service'; +import { Credential } from './credential.entity'; +import { CredentialSearchAnalyticsService } from './credential-search-analytics.service'; + +describe('CredentialSearchService', () => { + let service: any; + + const mockQueryBuilder = { + andWhere: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[{ id: '1' }], 1]), + }; + + const mockRepository = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + }; + + const mockAnalyticsService = { + logSearch: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CredentialSearchService, + { + provide: getRepositoryToken(Credential), + useValue: mockRepository, + }, + { + provide: CredentialSearchAnalyticsService, + useValue: mockAnalyticsService, + }, + ], + }).compile(); + + service = module.get(CredentialSearchService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should construct a query builder and apply filters', async () => { + const filters = { status: 'active', customDataField: 'value' }; + const result = await service.search('query text', filters, 1, 10); + + expect(mockRepository.createQueryBuilder).toHaveBeenCalledWith('credential'); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + expect.stringContaining('to_tsvector'), + { query: 'query text' } + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'credential.status = :status', + { status: 'active' } + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'credential.data->>:key = :value', + { key: 'customDataField', value: 'value' } + ); + expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0); + expect(mockQueryBuilder.take).toHaveBeenCalledWith(10); + expect(result.data.length).toBe(1); + expect(result.total).toBe(1); + expect(mockAnalyticsService.logSearch).toHaveBeenCalled(); + }); +}); diff --git a/backend/src/credentials/credential-search.service.ts b/backend/src/credentials/credential-search.service.ts new file mode 100644 index 00000000..88bf905a --- /dev/null +++ b/backend/src/credentials/credential-search.service.ts @@ -0,0 +1,99 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Credential } from './credential.entity'; +import { CredentialSearchAnalyticsService } from './credential-search-analytics.service'; + +export interface SearchFilters { + status?: string; + type?: string; + issuer?: string; + holder?: string; + startDate?: Date; + endDate?: Date; + [key: string]: any; // Allow data JSON filters +} + +export interface SearchResult { + data: Credential[]; + total: number; + page: number; + limit: number; +} + +@Injectable() +export class CredentialSearchService { + constructor( + @InjectRepository(Credential) + private readonly credentialRepository: Repository, + private readonly analyticsService: CredentialSearchAnalyticsService, + ) {} + + async search( + query: string, + filters: SearchFilters = {}, + page: number = 1, + limit: number = 20, + ): Promise { + const startTime = Date.now(); + const queryBuilder = this.credentialRepository.createQueryBuilder('credential'); + + if (query && query.trim() !== '') { + // Use PostgreSQL full-text search across multiple columns + // Note: simple concatenation with spaces for tsvector casting + queryBuilder.andWhere( + `to_tsvector('simple', coalesce(credential.type, '') || ' ' || coalesce(credential.issuer, '') || ' ' || coalesce(credential.holder, '')) @@ plainto_tsquery('simple', :query)`, + { query } + ); + } + + // Apply exact match standard filters + if (filters.status) { + queryBuilder.andWhere('credential.status = :status', { status: filters.status }); + } + if (filters.type) { + queryBuilder.andWhere('credential.type = :type', { type: filters.type }); + } + if (filters.issuer) { + queryBuilder.andWhere('credential.issuer = :issuer', { issuer: filters.issuer }); + } + if (filters.holder) { + queryBuilder.andWhere('credential.holder = :holder', { holder: filters.holder }); + } + if (filters.startDate) { + queryBuilder.andWhere('credential.createdAt >= :startDate', { startDate: filters.startDate }); + } + if (filters.endDate) { + queryBuilder.andWhere('credential.createdAt <= :endDate', { endDate: filters.endDate }); + } + + // Filter by JSON data if additional filters are provided + Object.keys(filters).forEach((key) => { + if (!['status', 'type', 'issuer', 'holder', 'startDate', 'endDate'].includes(key)) { + queryBuilder.andWhere(`credential.data->>:key = :value`, { + key, + value: filters[key], + }); + } + }); + + const skip = (page - 1) * limit; + queryBuilder.skip(skip).take(limit); + + // Order by created date descending by default + queryBuilder.orderBy('credential.createdAt', 'DESC'); + + const [data, total] = await queryBuilder.getManyAndCount(); + const executionTimeMs = Date.now() - startTime; + + // Fire off analytics logging asynchronously + this.analyticsService.logSearch(query, filters, total, executionTimeMs); + + return { + data, + total, + page, + limit, + }; + } +} diff --git a/backend/src/credentials/credentials.module.ts b/backend/src/credentials/credentials.module.ts index bbd5ceae..2a68064a 100644 --- a/backend/src/credentials/credentials.module.ts +++ b/backend/src/credentials/credentials.module.ts @@ -11,6 +11,11 @@ import { CredentialExport } from '../credential-export/credential-export.entity' import { AuditModule } from '../audit/audit.module'; import { AuthModule } from '../auth/auth.module'; +import { CredentialSearchAnalytics } from './credential-search-analytics.entity'; +import { CredentialSearchAnalyticsService } from './credential-search-analytics.service'; +import { CredentialSearchService } from './credential-search.service'; +import { CredentialSearchController } from './credential-search.controller'; + @Module({ imports: [ TypeOrmModule.forFeature([ @@ -18,20 +23,24 @@ import { AuthModule } from '../auth/auth.module'; AccessPermission, CredentialVersion, CredentialExport, + CredentialSearchAnalytics, ]), AuditModule, AuthModule, ], - controllers: [SecureDeletionController], + controllers: [SecureDeletionController, CredentialSearchController], providers: [ CredentialMigrationService, CredentialDeduplicationService, SecureDeletionService, + CredentialSearchAnalyticsService, + CredentialSearchService, ], exports: [ CredentialMigrationService, CredentialDeduplicationService, SecureDeletionService, + CredentialSearchService, ], }) export class CredentialsModule {}