Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { TelegramBotModule } from './telegram-bot/telegram-bot.module';
import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor';
import { DeprecationInterceptor } from './common/interceptors/deprecation.interceptor';
import { SearchModule } from './search/search.module';
import { SavedSearchModule } from './saved-search/saved-search.module';
import { ExportModule } from './export/export.module';
import { SignalsModule } from './signals/signals.module';
import { AnalyticsModule } from './analytics/analytics.module';
Expand Down Expand Up @@ -131,6 +132,7 @@ import { ContributorFeedModule } from './contributor-feed/contributor-feed.modul
TelegramBotModule,
ModerationModule,
SearchModule,
SavedSearchModule,
FeatureFlagsModule,
CrowdfundModule,
ContributorRegistryModule,
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/src/grants/grants-leaderboard.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { NotFoundException } from '@nestjs/common';
import { getQueueToken } from '@nestjs/bullmq';
import { GrantsService } from './grants.service';
import { CONTRIBUTION_QUEUE } from '../suspicious-contribution/types';
import { SavedSearchService } from '../saved-search/saved-search.service';

describe('GrantsService.getLeaderboard', () => {
let service: GrantsService;
Expand All @@ -18,6 +19,10 @@ describe('GrantsService.getLeaderboard', () => {
provide: getQueueToken(CONTRIBUTION_QUEUE),
useValue: { add: jest.fn().mockResolvedValue(undefined) },
},
{
provide: SavedSearchService,
useValue: { handleNewItem: jest.fn().mockResolvedValue(undefined) },
},
],
}).compile();

Expand Down
3 changes: 2 additions & 1 deletion apps/backend/src/grants/grants.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { GrantsController } from './grants.controller';
import { GrantsService } from './grants.service';
import { AdminAuditModule } from '../admin-audit/admin-audit.module';
import { SuspiciousContributionModule } from '../suspicious-contribution/suspicious-contribution.module';
import { SavedSearchModule } from '../saved-search/saved-search.module';

@Module({
imports: [AdminAuditModule, SuspiciousContributionModule],
imports: [AdminAuditModule, SuspiciousContributionModule, SavedSearchModule],
controllers: [GrantsController],
providers: [GrantsService],
exports: [GrantsService],
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/src/grants/grants.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { getQueueToken } from '@nestjs/bullmq';
import { GrantsService } from './grants.service';
import { CONTRIBUTION_QUEUE } from '../suspicious-contribution/types';
import { SavedSearchService } from '../saved-search/saved-search.service';

describe('GrantsService', () => {
let service: GrantsService;
Expand All @@ -21,6 +22,10 @@ describe('GrantsService', () => {
GrantsService,
{ provide: ConfigService, useValue: { get: jest.fn() } },
{ provide: getQueueToken(CONTRIBUTION_QUEUE), useValue: mockQueue },
{
provide: SavedSearchService,
useValue: { handleNewItem: jest.fn().mockResolvedValue(undefined) },
},
],
}).compile();
service = module.get<GrantsService>(GrantsService);
Expand Down
18 changes: 18 additions & 0 deletions apps/backend/src/grants/grants.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
CONTRIBUTION_QUEUE,
DETECTION_JOB,
} from '../suspicious-contribution/types';
import { SavedSearchService } from '../saved-search/saved-search.service';
import { SavedSearchDomain } from '../saved-search/saved-search.entity';

/**
* In-memory store for round and contribution data.
Expand Down Expand Up @@ -57,6 +59,7 @@ export class GrantsService {
constructor(
private readonly config: ConfigService,
@InjectQueue(CONTRIBUTION_QUEUE) private readonly suspiciousQueue: Queue,
private readonly savedSearchService: SavedSearchService,
) {
if (
process.env.NODE_ENV !== 'production' &&
Expand Down Expand Up @@ -209,6 +212,21 @@ export class GrantsService {
};
this.rounds.set(id, record);
this.logger.log(`Round ${id} created: ${dto.name}`);

// Trigger saved search matcher
this.savedSearchService
.handleNewItem(SavedSearchDomain.GRANTS, {
id,
name: dto.name,
tokenAddress: dto.tokenAddress,
})
.catch((err) => {
this.logger.error(
'Failed to trigger saved search for grant round creation',
err,
);
});

return this.toRoundDto(record);
}

Expand Down
3 changes: 1 addition & 2 deletions apps/backend/src/health/latency-budget.health.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,7 @@ export class LatencyBudgetHealthService {
*/
async getLatencyBudgetReport(): Promise<LatencyBudgetReport> {
const network = (process.env.STELLAR_NETWORK ?? 'testnet') as
| 'testnet'
| 'mainnet';
'testnet' | 'mainnet';

const horizonUrl =
process.env.STELLAR_HORIZON_URL ?? DEFAULT_HORIZON_URLS[network];
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/news/news.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { NewsSentimentService } from './news-sentiment.services';
import { AppCacheModule } from '../cache/cache.module';
import { ProfilingModule } from '../common/profiling/profiling.module';
import { SchedulerModule } from '../scheduler/scheduler.module';
import { SavedSearchModule } from '../saved-search/saved-search.module';

@Module({
imports: [
Expand All @@ -21,6 +22,7 @@ import { SchedulerModule } from '../scheduler/scheduler.module';
AppCacheModule,
ProfilingModule,
SchedulerModule,
SavedSearchModule,
],
controllers: [NewsController],
providers: [NewsProviderService, NewsService, NewsSentimentService],
Expand Down
28 changes: 27 additions & 1 deletion apps/backend/src/news/news.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { CacheService } from '../cache/cache.service';
import { QueryProfilerService } from '../common/profiling/query-profiler.service';
import { JobLockService } from '../scheduler/job-lock.service';
import { JobHistoryService } from '../scheduler/job-history.service';
import { SavedSearchService } from '../saved-search/saved-search.service';
import { SavedSearchDomain } from '../saved-search/saved-search.entity';

const FETCH_JOB_NAME = 'news-fetch';

Expand All @@ -37,12 +39,24 @@ export class NewsService {
private readonly profiler: QueryProfilerService,
private readonly jobLock: JobLockService,
private readonly jobHistory: JobHistoryService,
private readonly savedSearchService: SavedSearchService,
) {}

async create(createArticleDto: CreateArticleDto): Promise<News> {
const news = this.newsRepository.create(createArticleDto);
const saved = await this.newsRepository.save(news);
await this.cacheService.invalidateNewsCache();

// Trigger saved search matcher
this.savedSearchService
.handleNewItem(SavedSearchDomain.NEWS, saved)
.catch((err) => {
this.logger.error(
'Failed to trigger saved search for news article creation',
err,
);
});

return saved;
}

Expand Down Expand Up @@ -188,7 +202,19 @@ export class NewsService {
category: articleDto.categories?.[0] ?? null,
});

return this.newsRepository.save(article);
const saved = await this.newsRepository.save(article);

// Trigger saved search matcher
this.savedSearchService
.handleNewItem(SavedSearchDomain.NEWS, saved)
.catch((err) => {
this.logger.error(
'Failed to trigger saved search for news article creation in createOrIgnore',
err,
);
});

return saved;
}

/**
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/notification/notification.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export enum NotificationType {
MODULE = 'module',
ADMIN = 'admin',
REPUTATION = 'reputation',
SAVED_SEARCH = 'saved_search',
}

export enum NotificationSeverity {
Expand Down
12 changes: 2 additions & 10 deletions apps/backend/src/portfolio/dto/portfolio-snapshot.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,7 @@ export class PortfolioSnapshotBatchStatusDto {
example: 'running',
})
status:
| 'queued'
| 'running'
| 'completed'
| 'completed_with_errors'
| 'failed';
'queued' | 'running' | 'completed' | 'completed_with_errors' | 'failed';

@ApiProperty({
description: 'Total users scheduled for snapshot generation',
Expand Down Expand Up @@ -208,11 +204,7 @@ export class TriggerSnapshotBatchResponseDto {
example: 'queued',
})
status:
| 'queued'
| 'running'
| 'completed'
| 'completed_with_errors'
| 'failed';
'queued' | 'running' | 'completed' | 'completed_with_errors' | 'failed';

@ApiProperty({
description: 'Total users scheduled for snapshot generation',
Expand Down
6 changes: 1 addition & 5 deletions apps/backend/src/portfolio/queue/portfolio-snapshot.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,7 @@ export interface PortfolioSnapshotUserJobData {
export interface PortfolioSnapshotBatchStatus {
batchId: string;
status:
| 'queued'
| 'running'
| 'completed'
| 'completed_with_errors'
| 'failed';
'queued' | 'running' | 'completed' | 'completed_with_errors' | 'failed';
total: number;
completed: number;
failed: number;
Expand Down
70 changes: 70 additions & 0 deletions apps/backend/src/saved-search/dto/saved-search.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsEnum,
IsOptional,
IsBoolean,
IsObject,
MaxLength,
} from 'class-validator';
import { SavedSearchDomain } from '../saved-search.entity';

export class CreateSavedSearchDto {
@ApiProperty({
description: 'A user-friendly name for this saved search',
example: 'Stellar projects with active vaults',
})
@IsString()
@MaxLength(255)
name: string;

@ApiProperty({
description: 'Domain of the search',
enum: SavedSearchDomain,
example: SavedSearchDomain.PROJECTS,
})
@IsEnum(SavedSearchDomain)
domain: SavedSearchDomain;

@ApiProperty({
description:
'Search query filters/parameters (suitable for both web & mobile)',
example: { q: 'stellar', status: 'VERIFIED' },
})
@IsObject()
query: Record<string, unknown>;

@ApiPropertyOptional({
description: 'Whether search subscription is active for notifications',
example: true,
})
@IsOptional()
@IsBoolean()
isSubscribed?: boolean;
}

export class SavedSearchResponseDto {
@ApiProperty({ description: 'Unique ID of the saved search' })
id: string;

@ApiProperty({ description: 'User ID' })
userId: string;

@ApiProperty({ description: 'Name of the saved search' })
name: string;

@ApiProperty({ description: 'Domain of the search', enum: SavedSearchDomain })
domain: SavedSearchDomain;

@ApiProperty({ description: 'Search query filters/parameters' })
query: Record<string, unknown>;

@ApiProperty({ description: 'Whether search subscription is active' })
isSubscribed: boolean;

@ApiProperty({ description: 'Created at timestamp' })
createdAt: Date;

@ApiProperty({ description: 'Updated at timestamp' })
updatedAt: Date;
}
91 changes: 91 additions & 0 deletions apps/backend/src/saved-search/saved-search.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
UseGuards,
Request,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiResponse,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { SavedSearchService } from './saved-search.service';
import {
CreateSavedSearchDto,
SavedSearchResponseDto,
} from './dto/saved-search.dto';

@ApiTags('saved-searches')
@ApiBearerAuth('JWT-auth')
@Controller('saved-searches')
@UseGuards(JwtAuthGuard)
export class SavedSearchController {
constructor(private readonly savedSearchService: SavedSearchService) {}

@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Save a discovery search query',
description:
'Save a search query/filters and subscribe to downstream notifications.',
})
@ApiResponse({
status: 201,
description: 'Saved search created successfully',
type: SavedSearchResponseDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async create(
@Request() req: any,
@Body() dto: CreateSavedSearchDto,
): Promise<SavedSearchResponseDto> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const userId = req.user.sub as string;
return this.savedSearchService.create(userId, dto);
}

@Get()
@ApiOperation({
summary: 'List user saved searches',
description:
'Returns all saved searches and subscriptions for the authenticated user.',
})
@ApiResponse({
status: 200,
description: 'List of saved searches retrieved successfully',
type: [SavedSearchResponseDto],
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async findAll(@Request() req: any): Promise<SavedSearchResponseDto[]> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const userId = req.user.sub as string;
return this.savedSearchService.findAll(userId);
}

@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: 'Delete a saved search',
description:
'Remove a saved search and unsubscribe from its notifications.',
})
@ApiResponse({
status: 204,
description: 'Saved search deleted successfully',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Saved search not found' })
async delete(@Request() req: any, @Param('id') id: string): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const userId = req.user.sub as string;
return this.savedSearchService.delete(userId, id);
}
}
Loading
Loading