-
Notifications
You must be signed in to change notification settings - Fork 38
feat: Implement Credential Search and Filtering #177
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, any>; | ||
|
|
||
| @Column({ type: 'int', default: 0 }) | ||
| resultCount: number; | ||
|
|
||
| @Column({ type: 'float', default: 0 }) | ||
| executionTimeMs: number; | ||
|
|
||
| @CreateDateColumn() | ||
| timestamp: Date; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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>(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(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<CredentialSearchAnalytics>, | ||
| ) {} | ||
|
|
||
| async logSearch( | ||
| query: string, | ||
| filters: Record<string, any>, | ||
| resultCount: number, | ||
| executionTimeMs: number, | ||
| ): Promise<void> { | ||
| 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); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
|
Comment on lines
+9
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Pass dynamic JSON filters to the search service. The method binds only the listed query parameters. It never collects unknown parameters into Add a validated query DTO with an explicit dynamic-filter field and pass that field to 🤖 Prompt for AI AgentsSource: Path instructions |
||
| ) { | ||
| 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); | ||
|
Comment on lines
+9
to
+33
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Validate pagination and date query parameters.
Use a query DTO with 🤖 Prompt for AI AgentsSource: Path instructions |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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>(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(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
|
Comment on lines
+7
to
+15
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Replace The search and analytics flow uses
As per path instructions, 📍 Affects 5 files
🤖 Prompt for AI AgentsSource: Path instructions |
||
|
|
||
| export interface SearchResult { | ||
| data: Credential[]; | ||
| total: number; | ||
| page: number; | ||
| limit: number; | ||
| } | ||
|
|
||
| @Injectable() | ||
| export class CredentialSearchService { | ||
| constructor( | ||
| @InjectRepository(Credential) | ||
| private readonly credentialRepository: Repository<Credential>, | ||
| private readonly analyticsService: CredentialSearchAnalyticsService, | ||
| ) {} | ||
|
|
||
| async search( | ||
| query: string, | ||
| filters: SearchFilters = {}, | ||
| page: number = 1, | ||
| limit: number = 20, | ||
| ): Promise<SearchResult> { | ||
| 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], | ||
| }); | ||
|
Comment on lines
+71
to
+76
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
target=$(fd -t f 'credential-search\.service\.ts$' . | head -n 1)
printf '%s\n' "TARGET=$target"
wc -l "$target"
ast-grep outline "$target" || true
printf '%s\n' '--- target lines 1-130 ---'
sed -n '1,130p' "$target"
printf '%s\n' '--- related files ---'
fd -t f . backend | rg 'credential|search|spec|test' | head -n 100
printf '%s\n' '--- relevant references ---'
rg -n --glob '!node_modules' 'credential\.data|filters\[key\]|startDate|endDate|credential-search' backend | head -n 200Repository: GuardZero144/ValidFi Length of output: 10787 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- credential search tests ---'
sed -n '1,180p' backend/src/credentials/credential-search.service.spec.ts
printf '%s\n' '--- dependency metadata ---'
for f in package.json backend/package.json yarn.lock package-lock.json pnpm-lock.yaml backend/yarn.lock; do
if [ -f "$f" ]; then
printf '%s\n' "--- $f ---"
rg -n -C 2 '"typeorm"|typeorm@|typeorm:' "$f" | head -n 80 || true
fi
done
printf '%s\n' '--- all QueryBuilder parameter-setting patterns in backend ---'
rg -n -C 2 'setParameters|andWhere\([^;]*\{[^}]*key|data->>:' backend/src backend/test 2>/dev/null | head -n 160 || true
printf '%s\n' '--- standalone binding model ---'
python3 - <<'PY'
filters = {"department": "cardiology", "region": "eu"}
reserved = {"status", "type", "issuer", "holder", "startDate", "endDate"}
clauses = []
parameters = {}
for key, value in filters.items():
if key not in reserved:
clauses.append("credential.data->>:key = :value")
parameters.update({"key": key, "value": value})
print("clauses:", clauses)
print("final parameters:", parameters)
print("bound clauses:", [
clause.replace(":key", repr(parameters["key"])).replace(":value", repr(parameters["value"]))
for clause in clauses
])
PYRepository: GuardZero144/ValidFi Length of output: 4107 🏁 Script executed: #!/bin/bash
set -eu
base='https://raw.githubusercontent.com/typeorm/typeorm/0.3.17/src'
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
curl -fsSL "$base/query-builder/QueryBuilder.ts" -o "$tmpdir/QueryBuilder.ts"
curl -fsSL "$base/driver/postgres/PostgresDriver.ts" -o "$tmpdir/PostgresDriver.ts"
printf '%s\n' '--- QueryBuilder parameter APIs ---'
rg -n -C 8 'setParameter|setParameters|getParameters' "$tmpdir/QueryBuilder.ts" | head -n 220
printf '%s\n' '--- PostgreSQL parameter replacement ---'
rg -n -C 12 'escapeQueryWithParameters|parameterIndexMap|parametersPrefix' "$tmpdir/PostgresDriver.ts" | head -n 240
printf '%s\n' '--- exact relevant implementations ---'
sed -n '385,435p' "$tmpdir/QueryBuilder.ts"
sed -n '435,470p' "$tmpdir/QueryBuilder.ts"Repository: GuardZero144/ValidFi Length of output: 10050 🏁 Script executed: #!/bin/bash
set -eu
base='https://raw.githubusercontent.com/typeorm/typeorm/0.3.17/src'
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
curl -fsSL "$base/driver/postgres/PostgresDriver.ts" -o "$tmp"
sed -n '818,875p' "$tmp"Repository: GuardZero144/ValidFi Length of output: 2032 Use unique parameter names for each JSON filter. Multiple filters currently reuse 🤖 Prompt for AI Agents |
||
| } | ||
| }); | ||
|
|
||
| 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, | ||
| }; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: GuardZero144/ValidFi
Length of output: 23855
🏁 Script executed:
Repository: GuardZero144/ValidFi
Length of output: 50377
🏁 Script executed:
Repository: GuardZero144/ValidFi
Length of output: 493
Protect credential search with authentication and authorization.
GET /credentials/searchhas only the globalThrottlerGuard. It has no JWT or credential-visibility guard, andCredentialSearchService.search()adds no requester or tenant predicate. Unauthenticated callers can search and receive credentials across tenants. ApplyJwtAuthGuardand restrict results to credentials visible torequest.user.walletAddress.🤖 Prompt for AI Agents
Source: Path instructions