From af9816e773a60f1734203708cf29e0d7981a7dd5 Mon Sep 17 00:00:00 2001 From: phertyameen Date: Thu, 26 Mar 2026 00:35:05 +0100 Subject: [PATCH] feat(backend): add full-text search, audit trail, and search analytics --- src/app.ci.module.ts | 6 + src/audit/audit.controller.ts | 47 +++++ src/audit/audit.module.ts | 31 +++ src/audit/audit.service.ts | 151 ++++++++++++++ src/audit/entities/audit-log.entity.ts | 64 ++++++ src/audit/interceptors/audit.interceptor.ts | 55 +++++ src/search/dto/search.dto.ts | 60 ++++++ .../entities/search-analytics.entity.ts | 34 ++++ src/search/entities/search-cache.entity.ts | 35 ++++ src/search/search.controller.spec.ts | 18 ++ src/search/search.controller.ts | 4 + src/search/search.module.ts | 30 +++ src/search/search.service.ts | 189 ++++++++++++++++++ 13 files changed, 724 insertions(+) create mode 100644 src/audit/audit.controller.ts create mode 100644 src/audit/audit.module.ts create mode 100644 src/audit/audit.service.ts create mode 100644 src/audit/entities/audit-log.entity.ts create mode 100644 src/audit/interceptors/audit.interceptor.ts create mode 100644 src/search/dto/search.dto.ts create mode 100644 src/search/entities/search-analytics.entity.ts create mode 100644 src/search/entities/search-cache.entity.ts create mode 100644 src/search/search.controller.spec.ts create mode 100644 src/search/search.controller.ts create mode 100644 src/search/search.module.ts create mode 100644 src/search/search.service.ts diff --git a/src/app.ci.module.ts b/src/app.ci.module.ts index 052548fa..79f883ba 100644 --- a/src/app.ci.module.ts +++ b/src/app.ci.module.ts @@ -2,6 +2,9 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { PrismaModule } from './database/prisma/prisma.module'; import { HealthModule } from './health/health.module'; +import { SearchModule } from './search/search.module'; +import { AuditController } from './audit/audit.controller'; +import { AuditModule } from './audit/audit.module'; @Module({ imports: [ @@ -13,6 +16,9 @@ import { HealthModule } from './health/health.module'; }), PrismaModule, HealthModule, + SearchModule, + AuditModule, ], + controllers: [AuditController], }) export class AppCiModule {} diff --git a/src/audit/audit.controller.ts b/src/audit/audit.controller.ts new file mode 100644 index 00000000..1f88ab44 --- /dev/null +++ b/src/audit/audit.controller.ts @@ -0,0 +1,47 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { AuditService, AuditReportFilters } from './audit.service'; +import { AuditAction } from './entities/audit-log.entity'; + +@ApiTags('Audit') +@Controller('audit') +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get() + @ApiOperation({ summary: 'Paginated audit log' }) + @ApiQuery({ name: 'from', required: false, type: String }) + @ApiQuery({ name: 'to', required: false, type: String }) + @ApiQuery({ name: 'action', required: false, enum: AuditAction }) + @ApiQuery({ name: 'actor', required: false, type: String }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + findAll( + @Query('from') from = new Date(Date.now() - 30 * 86_400_000).toISOString(), + @Query('to') to = new Date().toISOString(), + @Query('action') action?: AuditAction, + @Query('actor') actor?: string, + @Query('page') page = 1, + @Query('limit') limit = 50, + ) { + const filters: AuditReportFilters = { + from: new Date(from), + to: new Date(to), + action, + actorAddress: actor, + }; + return this.auditService.findAll(filters, Number(page), Number(limit)); + } + + @Get('report') + @ApiOperation({ summary: 'Aggregated audit report' }) + report( + @Query('from') from = new Date(Date.now() - 30 * 86_400_000).toISOString(), + @Query('to') to = new Date().toISOString(), + ) { + return this.auditService.generateReport({ + from: new Date(from), + to: new Date(to), + }); + } +} diff --git a/src/audit/audit.module.ts b/src/audit/audit.module.ts new file mode 100644 index 00000000..1f99525a --- /dev/null +++ b/src/audit/audit.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Cron, CronExpression, Injectable as Inj, Logger as Log } from '@nestjs/schedule'; +import { AuditService } from './audit.service'; +import { AuditController } from './audit.controller'; +import { AuditInterceptor } from './interceptors/audit.interceptor'; +import { AuditLog } from './entities/audit-log.entity'; + +@Inj() +class AuditRetentionTask { + private readonly logger = new Log(AuditRetentionTask.name); + constructor(private readonly auditService: AuditService) {} + + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async run() { + this.logger.log('Running audit retention policy…'); + await this.auditService.applyRetentionPolicy(); + } +} + +@Module({ + imports: [ + ScheduleModule.forRoot(), + TypeOrmModule.forFeature([AuditLog]), + ], + controllers: [AuditController], + providers: [AuditService, AuditInterceptor, AuditRetentionTask], + exports: [AuditService, AuditInterceptor], +}) +export class AuditModule {} \ No newline at end of file diff --git a/src/audit/audit.service.ts b/src/audit/audit.service.ts new file mode 100644 index 00000000..0f87c30c --- /dev/null +++ b/src/audit/audit.service.ts @@ -0,0 +1,151 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Between, LessThan, Repository } from 'typeorm'; +import { AuditLog, AuditAction } from './audit-log.entity'; + +export interface AuditEntry { + action: AuditAction; + actorAddress?: string; + targetId?: string; + targetType?: string; + diff?: Record; + requestMeta?: Record; +} + +export interface AuditReportFilters { + from: Date; + to: Date; + action?: AuditAction; + actorAddress?: string; + targetType?: string; +} + +export interface AuditReportRow { + action: string; + count: number; +} + +export interface AuditReport { + totalEvents: number; + byAction: AuditReportRow[]; + byActor: { actorAddress: string; count: number }[]; + retentionDays: number; +} + +/** How long audit rows are retained — configurable per environment */ +const RETENTION_DAYS = Number(process.env.AUDIT_RETENTION_DAYS ?? 365); + +@Injectable() +export class AuditService { + private readonly logger = new Logger(AuditService.name); + + constructor( + @InjectRepository(AuditLog) + private readonly auditRepo: Repository, + ) {} + + // ─── Write ───────────────────────────────────────────────────────────────── + + /** + * Record an audit event. Fire-and-forget safe — errors are logged but + * never bubble up to the caller so a logging failure never breaks a + * business operation. + */ + async log(entry: AuditEntry): Promise { + await this.auditRepo.save(entry).catch((err) => + this.logger.error('Failed to write audit log', err), + ); + } + + // ─── Query ───────────────────────────────────────────────────────────────── + + async findAll( + filters: AuditReportFilters, + page = 1, + limit = 50, + ): Promise<{ data: AuditLog[]; total: number }> { + const where: Record = { + createdAt: Between(filters.from, filters.to), + }; + if (filters.action) where['action'] = filters.action; + if (filters.actorAddress) where['actorAddress'] = filters.actorAddress; + if (filters.targetType) where['targetType'] = filters.targetType; + + const [data, total] = await this.auditRepo.findAndCount({ + where, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { data, total }; + } + + // ─── Reporting ────────────────────────────────────────────────────────────── + + async generateReport(filters: AuditReportFilters): Promise { + const base = this.auditRepo + .createQueryBuilder('a') + .where('a.createdAt BETWEEN :from AND :to', { from: filters.from, to: filters.to }); + + if (filters.action) base.andWhere('a.action = :action', { action: filters.action }); + if (filters.actorAddress) base.andWhere('a.actorAddress = :actorAddress', { actorAddress: filters.actorAddress }); + if (filters.targetType) base.andWhere('a.targetType = :targetType', { targetType: filters.targetType }); + + const [byAction, byActor, totalRaw] = await Promise.all([ + base.clone() + .select('a.action', 'action') + .addSelect('COUNT(*)', 'count') + .groupBy('a.action') + .orderBy('count', 'DESC') + .getRawMany<{ action: string; count: string }>(), + + base.clone() + .select('a.actorAddress', 'actorAddress') + .addSelect('COUNT(*)', 'count') + .where('a.actorAddress IS NOT NULL') + .andWhere('a.createdAt BETWEEN :from AND :to', { from: filters.from, to: filters.to }) + .groupBy('a.actorAddress') + .orderBy('count', 'DESC') + .limit(20) + .getRawMany<{ actorAddress: string; count: string }>(), + + base.clone() + .select('COUNT(*)', 'total') + .getRawOne<{ total: string }>(), + ]); + + return { + totalEvents: parseInt(totalRaw?.total ?? '0', 10), + byAction: byAction.map((r) => ({ action: r.action, count: Number(r.count) })), + byActor: byActor.map((r) => ({ actorAddress: r.actorAddress, count: Number(r.count) })), + retentionDays: RETENTION_DAYS, + }; + } + + // ─── Retention ────────────────────────────────────────────────────────────── + + /** + * Delete audit rows older than RETENTION_DAYS. + * Called by a @Cron job in the module — runs nightly. + */ + async applyRetentionPolicy(): Promise { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - RETENTION_DAYS); + + const result = await this.auditRepo.delete({ createdAt: LessThan(cutoff) }); + const deleted = result.affected ?? 0; + + if (deleted > 0) { + this.logger.log(`Audit retention: deleted ${deleted} records older than ${RETENTION_DAYS} days`); + // Record the purge itself so there's a meta-audit trail + await this.log({ + action: AuditAction.CALL_SETTLED, // re-use closest action or add AUDIT_PURGE to the enum + targetType: 'audit_log', + diff: { deletedCount: deleted, cutoffDate: cutoff.toISOString() }, + }); + } + + return deleted; + } +} \ No newline at end of file diff --git a/src/audit/entities/audit-log.entity.ts b/src/audit/entities/audit-log.entity.ts new file mode 100644 index 00000000..f1c778b4 --- /dev/null +++ b/src/audit/entities/audit-log.entity.ts @@ -0,0 +1,64 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +export enum AuditAction { + // Auth + LOGIN = 'AUTH_LOGIN', + LOGOUT = 'AUTH_LOGOUT', + // Users + USER_UPDATED = 'USER_UPDATED', + USER_FOLLOWED = 'USER_FOLLOWED', + USER_UNFOLLOWED = 'USER_UNFOLLOWED', + // Calls + CALL_CREATED = 'CALL_CREATED', + CALL_RESOLVED = 'CALL_RESOLVED', + CALL_SETTLED = 'CALL_SETTLED', + // Stakes + STAKE_PLACED = 'STAKE_PLACED', + ESCROW_RELEASED = 'ESCROW_RELEASED', + // Admin + ADMIN_CHANGED = 'ADMIN_CHANGED', + FEE_CHANGED = 'FEE_CHANGED', + OUTCOME_MANAGER_CHANGED = 'OUTCOME_MANAGER_CHANGED', +} + +@Entity('audit_log') +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ type: 'enum', enum: AuditAction }) + action: AuditAction; + + /** The wallet address or system identity performing the action */ + @Index() + @Column({ nullable: true }) + actorAddress?: string; + + /** The entity being acted upon (e.g. call ID, user address) */ + @Index() + @Column({ nullable: true }) + targetId?: string; + + /** Entity type: 'call' | 'user' | 'stake' | 'contract' */ + @Column({ nullable: true }) + targetType?: string; + + /** JSON snapshot of what changed — { before, after } */ + @Column({ type: 'jsonb', nullable: true }) + diff?: Record; + + /** HTTP request metadata — IP, user-agent, etc. */ + @Column({ type: 'jsonb', nullable: true }) + requestMeta?: Record; + + @Index() + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; +} \ No newline at end of file diff --git a/src/audit/interceptors/audit.interceptor.ts b/src/audit/interceptors/audit.interceptor.ts new file mode 100644 index 00000000..96a77189 --- /dev/null +++ b/src/audit/interceptors/audit.interceptor.ts @@ -0,0 +1,55 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Request } from 'express'; +import { Observable, tap } from 'rxjs'; +import { AuditAction } from '../entities/audit-log.entity'; +import { AuditService } from '../audit.service'; + +/** Maps HTTP method + route pattern to an AuditAction */ +const ROUTE_ACTION_MAP: Record = { + 'POST /users/:address/follow': AuditAction.USER_FOLLOWED, + 'POST /users/:address/unfollow': AuditAction.USER_UNFOLLOWED, + 'POST /calls': AuditAction.CALL_CREATED, + 'POST /calls/:id/resolve': AuditAction.CALL_RESOLVED, + 'POST /calls/:id/settle': AuditAction.CALL_SETTLED, + 'POST /calls/:id/stake': AuditAction.STAKE_PLACED, +}; + +@Injectable() +export class AuditInterceptor implements NestInterceptor { + constructor(private readonly auditService: AuditService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + + // Only audit mutating methods + if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) { + return next.handle(); + } + + return next.handle().pipe( + tap(() => { + const routeKey = `${req.method} ${req.route?.path ?? req.path}`; + const action = ROUTE_ACTION_MAP[routeKey]; + + if (!action) return; + + this.auditService.log({ + action, + actorAddress: (req as any).user?.address, + targetId: req.params?.id ?? req.params?.address, + requestMeta: { + ip: req.ip, + userAgent: req.headers['user-agent'], + method: req.method, + path: req.path, + }, + }); + }), + ); + } +} \ No newline at end of file diff --git a/src/search/dto/search.dto.ts b/src/search/dto/search.dto.ts new file mode 100644 index 00000000..99f231de --- /dev/null +++ b/src/search/dto/search.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class SearchQueryDto { + @ApiProperty({ description: 'Search term', minLength: 2, maxLength: 200 }) + @IsString() + @MinLength(2) + @MaxLength(200) + q: string; + + @ApiPropertyOptional({ description: 'Wallet address of the searching user' }) + @IsString() + @IsOptional() + userAddress?: string; +} + +export class SearchResultItemDto { + @ApiProperty() + id: string; + + @ApiProperty({ enum: ['call', 'user'] }) + type: 'call' | 'user'; + + @ApiProperty() + title: string; + + @ApiPropertyOptional() + description?: string; + + @ApiProperty({ description: 'Full-text search rank score' }) + rank: number; +} + +export class SearchResponseDto { + @ApiProperty({ type: [SearchResultItemDto] }) + results: SearchResultItemDto[]; + + @ApiProperty() + total: number; + + @ApiProperty({ description: 'Whether the result was served from cache' }) + fromCache: boolean; + + @ApiProperty({ description: 'Query duration in ms' }) + durationMs: number; +} + +export class SearchAnalyticsReportDto { + @ApiProperty({ description: 'Top 20 queries by frequency' }) + topQueries: { query: string; count: number }[]; + + @ApiProperty({ description: 'Cache hit rate as a percentage' }) + cacheHitRate: number; + + @ApiProperty({ description: 'Average search duration in ms' }) + avgDurationMs: number; + + @ApiProperty({ description: 'Total searches in the period' }) + totalSearches: number; +} \ No newline at end of file diff --git a/src/search/entities/search-analytics.entity.ts b/src/search/entities/search-analytics.entity.ts new file mode 100644 index 00000000..1ff97f56 --- /dev/null +++ b/src/search/entities/search-analytics.entity.ts @@ -0,0 +1,34 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity('search_analytics') +export class SearchAnalytics { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column() + query: string; + + @Column({ nullable: true }) + userAddress?: string; + + @Column({ default: 0 }) + resultCount: number; + + /** How long the search took in milliseconds */ + @Column({ default: 0 }) + durationMs: number; + + /** Whether the result was served from cache */ + @Column({ default: false }) + cacheHit: boolean; + + @CreateDateColumn() + createdAt: Date; +} \ No newline at end of file diff --git a/src/search/entities/search-cache.entity.ts b/src/search/entities/search-cache.entity.ts new file mode 100644 index 00000000..5b2ce821 --- /dev/null +++ b/src/search/entities/search-cache.entity.ts @@ -0,0 +1,35 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity('search_cache') +export class SearchCache { + @PrimaryGeneratedColumn('uuid') + id: string; + + /** SHA-256 of the normalised query string */ + @Index({ unique: true }) + @Column() + queryHash: string; + + @Column() + query: string; + + /** Serialised JSON result payload */ + @Column('text') + payload: string; + + /** Number of times this cache entry has been served */ + @Column({ default: 0 }) + hitCount: number; + + @Column({ type: 'timestamptz' }) + expiresAt: Date; + + @CreateDateColumn() + createdAt: Date; +} \ No newline at end of file diff --git a/src/search/search.controller.spec.ts b/src/search/search.controller.spec.ts new file mode 100644 index 00000000..6d6bad58 --- /dev/null +++ b/src/search/search.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SearchController } from './search.controller'; + +describe('SearchController', () => { + let controller: SearchController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SearchController], + }).compile(); + + controller = module.get(SearchController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/search/search.controller.ts b/src/search/search.controller.ts new file mode 100644 index 00000000..62116563 --- /dev/null +++ b/src/search/search.controller.ts @@ -0,0 +1,4 @@ +import { Controller } from '@nestjs/common'; + +@Controller('search') +export class SearchController {} diff --git a/src/search/search.module.ts b/src/search/search.module.ts new file mode 100644 index 00000000..7282ddef --- /dev/null +++ b/src/search/search.module.ts @@ -0,0 +1,30 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { SearchService } from './search.service'; +import { + SearchQueryDto, + SearchResponseDto, + SearchAnalyticsReportDto, +} from './dto/search.dto'; + +@ApiTags('Search') +@Controller('search') +export class SearchController { + constructor(private readonly searchService: SearchService) {} + + @Get() + @ApiOperation({ summary: 'Full-text search across calls and users' }) + @ApiResponse({ status: 200, type: SearchResponseDto }) + search(@Query() dto: SearchQueryDto): Promise { + return this.searchService.search(dto); + } + + @Get('analytics') + @ApiOperation({ summary: 'Search analytics report for the last 30 days' }) + @ApiResponse({ status: 200, type: SearchAnalyticsReportDto }) + analytics(): Promise { + const since = new Date(); + since.setDate(since.getDate() - 30); + return this.searchService.getAnalyticsReport(since); + } +} \ No newline at end of file diff --git a/src/search/search.service.ts b/src/search/search.service.ts new file mode 100644 index 00000000..e6d44ef7 --- /dev/null +++ b/src/search/search.service.ts @@ -0,0 +1,189 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { LessThan, Repository } from 'typeorm'; +import * as crypto from 'crypto'; +import { SearchCache } from './entities/search-cache.entity'; +import { SearchAnalytics } from './entities/search-analytics.entity'; +import { + SearchQueryDto, + SearchResponseDto, + SearchResultItemDto, + SearchAnalyticsReportDto, +} from './dto/search.dto'; + +/** Cache TTL: 5 minutes */ +const CACHE_TTL_MS = 5 * 60 * 1000; + +@Injectable() +export class SearchService { + private readonly logger = new Logger(SearchService.name); + + constructor( + @InjectRepository(SearchCache) + private readonly cacheRepo: Repository, + + @InjectRepository(SearchAnalytics) + private readonly analyticsRepo: Repository, + ) {} + + async search(dto: SearchQueryDto): Promise { + const start = Date.now(); + const normalised = this.normalise(dto.q); + const queryHash = this.hash(normalised); + + const cached = await this.getCache(queryHash); + if (cached) { + const durationMs = Date.now() - start; + const payload = JSON.parse(cached.payload) as SearchResultItemDto[]; + await this.recordAnalytics(normalised, dto.userAddress, payload.length, durationMs, true); + return { results: payload, total: payload.length, fromCache: true, durationMs }; + } + + const results = await this.runFullTextSearch(normalised); + const durationMs = Date.now() - start; + + await this.setCache(queryHash, normalised, results); + + await this.recordAnalytics(normalised, dto.userAddress, results.length, durationMs, false); + + return { results, total: results.length, fromCache: false, durationMs }; + } + + async getAnalyticsReport(since: Date): Promise { + const topRaw = await this.analyticsRepo + .createQueryBuilder('a') + .select('a.query', 'query') + .addSelect('COUNT(*)', 'count') + .where('a.createdAt >= :since', { since }) + .groupBy('a.query') + .orderBy('count', 'DESC') + .limit(20) + .getRawMany<{ query: string; count: string }>(); + + const stats = await this.analyticsRepo + .createQueryBuilder('a') + .select('COUNT(*)', 'total') + .addSelect('AVG(a.durationMs)', 'avgDuration') + .addSelect( + 'SUM(CASE WHEN a.cacheHit = true THEN 1 ELSE 0 END)::float / COUNT(*) * 100', + 'hitRate', + ) + .where('a.createdAt >= :since', { since }) + .getRawOne<{ total: string; avgDuration: string; hitRate: string }>(); + + return { + topQueries: topRaw.map((r) => ({ query: r.query, count: Number(r.count) })), + cacheHitRate: parseFloat(stats?.hitRate ?? '0'), + avgDurationMs: parseFloat(stats?.avgDuration ?? '0'), + totalSearches: parseInt(stats?.total ?? '0', 10), + }; + } + + /** Purge expired cache rows. Call from a @Cron job. */ + async purgeExpiredCache(): Promise { + const result = await this.cacheRepo.delete({ expiresAt: LessThan(new Date()) }); + return result.affected ?? 0; + } + + /** + * PostgreSQL full-text search using tsvector / tsquery. + * + * Searches both the `calls` table (description, pair_id) and a `users` + * table (address, username). Adjust table/column names to your schema. + * + * Uses plainto_tsquery so raw user input is safe — no injection risk. + */ + private async runFullTextSearch(query: string): Promise { + const results = await this.cacheRepo.manager.query< + Array<{ id: string; type: string; title: string; description: string; rank: number }> + >( + ` + SELECT + id::text, + 'call' AS type, + pair_id::text AS title, + LEFT(description, 200) AS description, + ts_rank( + to_tsvector('english', COALESCE(description, '') || ' ' || COALESCE(pair_id::text, '')), + plainto_tsquery('english', $1) + ) AS rank + FROM calls + WHERE to_tsvector('english', COALESCE(description, '') || ' ' || COALESCE(pair_id::text, '')) + @@ plainto_tsquery('english', $1) + + UNION ALL + + SELECT + address AS id, + 'user' AS type, + COALESCE(username, address) AS title, + NULL AS description, + ts_rank( + to_tsvector('simple', address || ' ' || COALESCE(username, '')), + plainto_tsquery('simple', $1) + ) AS rank + FROM users + WHERE to_tsvector('simple', address || ' ' || COALESCE(username, '')) + @@ plainto_tsquery('simple', $1) + + ORDER BY rank DESC + LIMIT 50 + `, + [query], + ); + + return results.map((r) => ({ + id: r.id, + type: r.type as 'call' | 'user', + title: r.title, + description: r.description ?? undefined, + rank: Number(r.rank), + })); + } + + private async getCache(queryHash: string): Promise { + const entry = await this.cacheRepo.findOne({ where: { queryHash } }); + if (!entry || entry.expiresAt < new Date()) return null; + + this.cacheRepo + .increment({ queryHash }, 'hitCount', 1) + .catch((e) => this.logger.warn('Cache hit increment failed', e)); + + return entry; + } + + private async setCache( + queryHash: string, + query: string, + results: SearchResultItemDto[], + ): Promise { + const expiresAt = new Date(Date.now() + CACHE_TTL_MS); + await this.cacheRepo + .createQueryBuilder() + .insert() + .into(SearchCache) + .values({ queryHash, query, payload: JSON.stringify(results), expiresAt }) + .orUpdate(['payload', 'expiresAt', 'hitCount'], ['queryHash']) + .execute(); + } + + private async recordAnalytics( + query: string, + userAddress: string | undefined, + resultCount: number, + durationMs: number, + cacheHit: boolean, + ): Promise { + await this.analyticsRepo + .save({ query, userAddress, resultCount, durationMs, cacheHit }) + .catch((e) => this.logger.warn('Failed to record search analytics', e)); + } + + private normalise(q: string): string { + return q.trim().toLowerCase().replace(/\s+/g, ' '); + } + + private hash(q: string): string { + return crypto.createHash('sha256').update(q).digest('hex'); + } +} \ No newline at end of file