diff --git a/backend/src/modules/search/search.controller.ts b/backend/src/modules/search/search.controller.ts index 7d969fca..ded53b9a 100644 --- a/backend/src/modules/search/search.controller.ts +++ b/backend/src/modules/search/search.controller.ts @@ -1,5 +1,5 @@ import type { Request, Response, NextFunction } from 'express'; -import { searchCreatorsQuerySchema } from './search.schema.js'; +import { searchCreatorsQuerySchema, trendingCreatorsQuerySchema } from './search.schema.js'; import * as searchService from './search.service.js'; /** GET /search/creators — search creators by name or username. */ @@ -16,3 +16,18 @@ export async function searchCreators( next(err); } } + +/** GET /search/trending — trending creators ranked by tip volume (issue #1016). */ +export async function getTrendingCreators( + req: Request, + res: Response, + next: NextFunction, +): Promise { + try { + const { window, limit, offset } = trendingCreatorsQuerySchema.parse(req.query); + const result = await searchService.getTrendingCreators(window, limit, offset); + res.status(200).json(result); + } catch (err) { + next(err); + } +} diff --git a/backend/src/modules/search/search.routes.ts b/backend/src/modules/search/search.routes.ts index e9073179..283c0edc 100644 --- a/backend/src/modules/search/search.routes.ts +++ b/backend/src/modules/search/search.routes.ts @@ -6,6 +6,7 @@ import { mergeOpenApiPaths } from '../../docs/openapi.js'; export const searchRouter = Router(); searchRouter.get('/creators', searchController.searchCreators); +searchRouter.get('/trending', searchController.getTrendingCreators); const base = `${env.API_BASE_PATH}/search`; @@ -22,6 +23,22 @@ const searchCreatorSchema = { required: ['id', 'stellarAddress'], }; +const trendingCreatorEntrySchema = { + type: 'object', + properties: { + rank: { type: 'integer', example: 1 }, + userId: { type: 'string', example: 'clxx1234567890abcdef' }, + username: { type: 'string', nullable: true, example: 'alice' }, + displayName: { type: 'string', nullable: true, example: 'Alice Star' }, + stellarAddress: { type: 'string', example: 'GA...1' }, + imageUrl: { type: 'string', nullable: true }, + bio: { type: 'string', nullable: true }, + totalTipsStroops: { type: 'string', example: '500000000' }, + tipCount: { type: 'integer', example: 42 }, + }, + required: ['rank', 'userId', 'stellarAddress', 'totalTipsStroops', 'tipCount'], +}; + mergeOpenApiPaths({ [`${base}/creators`]: { get: { @@ -79,4 +96,61 @@ mergeOpenApiPaths({ }, }, }, + [`${base}/trending`]: { + get: { + tags: ['Search'], + summary: 'Get trending creators', + description: + 'Returns creators ranked by confirmed tip volume within a time window. Useful for discovering popular creators.', + parameters: [ + { + name: 'window', + in: 'query', + required: false, + schema: { type: 'string', enum: ['24h', '7d', '30d'], default: '7d' }, + description: 'Time window for trending calculation', + }, + { + name: 'limit', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, + }, + { + name: 'offset', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 0, default: 0 }, + }, + ], + responses: { + '200': { + description: 'Trending creators', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { type: 'array', items: trendingCreatorEntrySchema }, + pagination: { + type: 'object', + properties: { + limit: { type: 'integer' }, + offset: { type: 'integer' }, + total: { type: 'integer' }, + hasMore: { type: 'boolean' }, + }, + required: ['limit', 'offset', 'total', 'hasMore'], + }, + window: { type: 'string', enum: ['24h', '7d', '30d'] }, + }, + required: ['data', 'pagination', 'window'], + }, + }, + }, + }, + '400': { description: 'Validation error' }, + }, + }, + }, }); diff --git a/backend/src/modules/search/search.schema.ts b/backend/src/modules/search/search.schema.ts index d4494aa7..7726fe8d 100644 --- a/backend/src/modules/search/search.schema.ts +++ b/backend/src/modules/search/search.schema.ts @@ -8,3 +8,12 @@ export const searchCreatorsQuerySchema = z.object({ }); export type SearchCreatorsQuery = z.infer; + +/** Query parameters for GET /search/trending (issue #1016). */ +export const trendingCreatorsQuerySchema = z.object({ + window: z.enum(['24h', '7d', '30d']).default('7d'), + limit: z.coerce.number().int().min(1).max(100).default(20), + offset: z.coerce.number().int().min(0).default(0), +}); + +export type TrendingCreatorsQuery = z.infer; diff --git a/backend/src/modules/search/search.service.ts b/backend/src/modules/search/search.service.ts index 439b969b..32d85d7e 100644 --- a/backend/src/modules/search/search.service.ts +++ b/backend/src/modules/search/search.service.ts @@ -1,4 +1,12 @@ import { prisma } from '../../db/prisma.js'; +import { logger } from '../../common/utils/logger.js'; +import type { SearchCreatorsResponse, TrendingCreatorEntry, TrendingCreatorsResponse } from './search.types.js'; + +const WINDOW_MS: Record = { + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, +}; import { redis } from '../../db/redis.js'; import { env } from '../../config/env.js'; import { logger } from '../../common/utils/logger.js'; @@ -87,3 +95,74 @@ export async function searchCreators( await writeCache(key, result); return result; } + +/** + * Returns trending creators ranked by confirmed tip volume within a time window. + * Supports 24h, 7d, and 30d windows with pagination (issue #1016). + */ +export async function getTrendingCreators( + window: string, + limit: number, + offset: number, +): Promise { + logger.info({ window, limit, offset }, 'Fetching trending creators'); + + const since = new Date(Date.now() - WINDOW_MS[window]); + const tipWhere = { + status: 'CONFIRMED' as const, + createdAt: { gte: since }, + }; + + const [grouped, total] = await Promise.all([ + prisma.tip.groupBy({ + by: ['toAddress'], + where: tipWhere, + _sum: { amountStroops: true }, + _count: true, + orderBy: { _sum: { amountStroops: 'desc' } }, + take: limit, + skip: offset, + }), + (await prisma.tip.groupBy({ by: ['toAddress'], where: tipWhere })).length, + ]); + + const addresses = grouped.map((row) => row.toAddress); + const users = await prisma.user.findMany({ + where: { stellarAddress: { in: addresses } }, + select: { + id: true, + username: true, + displayName: true, + stellarAddress: true, + imageUrl: true, + bio: true, + }, + }); + const userMap = new Map(users.map((u) => [u.stellarAddress, u])); + + const data: TrendingCreatorEntry[] = grouped.map((row, index) => { + const user = userMap.get(row.toAddress); + return { + rank: offset + index + 1, + userId: user?.id ?? '', + username: user?.username ?? null, + displayName: user?.displayName ?? null, + stellarAddress: row.toAddress, + imageUrl: user?.imageUrl ?? null, + bio: user?.bio ?? null, + totalTipsStroops: (row._sum.amountStroops ?? 0n).toString(), + tipCount: row._count, + }; + }); + + return { + data, + pagination: { + limit, + offset, + total, + hasMore: offset + data.length < total, + }, + window, + }; +} diff --git a/backend/src/modules/search/search.test.ts b/backend/src/modules/search/search.test.ts index d0774728..7501f729 100644 --- a/backend/src/modules/search/search.test.ts +++ b/backend/src/modules/search/search.test.ts @@ -3,6 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createApp } from '../../app.js'; import { searchCreators } from './search.service.js'; +const { mockFindMany, mockCount, mockGroupBy, mockFindUnique } = vi.hoisted(() => ({ + mockFindMany: vi.fn(), + mockCount: vi.fn(), + mockGroupBy: vi.fn(), + mockFindUnique: vi.fn(), const { mockFindMany, mockCount, mockRedisGet, mockRedisSet } = vi.hoisted(() => ({ mockFindMany: vi.fn(), mockCount: vi.fn(), @@ -12,7 +17,8 @@ const { mockFindMany, mockCount, mockRedisGet, mockRedisSet } = vi.hoisted(() => vi.mock('../../db/prisma.js', () => ({ prisma: { - user: { findMany: mockFindMany, count: mockCount }, + user: { findMany: mockFindMany, count: mockCount, findUnique: mockFindUnique }, + tip: { groupBy: mockGroupBy }, $disconnect: vi.fn(), }, })); @@ -177,6 +183,92 @@ describe('searchCreators service', () => { }); }); +describe('GET /api/v1/search/trending', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGroupBy.mockResolvedValue([]); + mockFindMany.mockResolvedValue([]); + mockCount.mockResolvedValue(0); + }); + + it('returns 200 with empty data by default', async () => { + const app = createApp(); + const res = await request(app).get('/api/v1/search/trending'); + + expect(res.status).toBe(200); + expect(res.body.data).toEqual([]); + expect(res.body.window).toBe('7d'); + expect(res.body.pagination).toEqual({ + limit: 20, + offset: 0, + total: 0, + hasMore: false, + }); + }); + + it('returns trending creators ranked by tip volume', async () => { + mockGroupBy + .mockResolvedValueOnce([ + { toAddress: 'GA1', _sum: { amountStroops: BigInt(500_000_000) }, _count: 42 }, + { toAddress: 'GA2', _sum: { amountStroops: BigInt(200_000_000) }, _count: 15 }, + ]) + .mockResolvedValueOnce([{}, {}]); + mockFindMany.mockResolvedValue([ + { id: 'user-1', username: 'alice', displayName: 'Alice Star', stellarAddress: 'GA1', imageUrl: null, bio: 'Creator' }, + { id: 'user-2', username: 'bob', displayName: 'Bob Art', stellarAddress: 'GA2', imageUrl: null, bio: 'Artist' }, + ]); + + const app = createApp(); + const res = await request(app).get('/api/v1/search/trending'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.data[0]).toEqual({ + rank: 1, + userId: 'user-1', + username: 'alice', + displayName: 'Alice Star', + stellarAddress: 'GA1', + imageUrl: null, + bio: 'Creator', + totalTipsStroops: '500000000', + tipCount: 42, + }); + expect(res.body.data[1]).toEqual({ + rank: 2, + userId: 'user-2', + username: 'bob', + displayName: 'Bob Art', + stellarAddress: 'GA2', + imageUrl: null, + bio: 'Artist', + totalTipsStroops: '200000000', + tipCount: 15, + }); + }); + + it('supports 24h window parameter', async () => { + mockGroupBy.mockResolvedValue([]).mockResolvedValue([]); + mockFindMany.mockResolvedValue([]); + + const app = createApp(); + const res = await request(app).get('/api/v1/search/trending?window=24h'); + + expect(res.status).toBe(200); + expect(res.body.window).toBe('24h'); + }); + + it('returns 400 for invalid window', async () => { + const app = createApp(); + const res = await request(app).get('/api/v1/search/trending?window=1y'); + + expect(res.status).toBe(400); + }); + + it('returns 400 for invalid limit', async () => { + const app = createApp(); + const res = await request(app).get('/api/v1/search/trending?limit=0'); + expect(res.status).toBe(400); describe('searchCreators caching', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/backend/src/modules/search/search.types.ts b/backend/src/modules/search/search.types.ts index 1a40b336..8b38a917 100644 --- a/backend/src/modules/search/search.types.ts +++ b/backend/src/modules/search/search.types.ts @@ -18,3 +18,28 @@ export interface SearchCreatorsResponse { hasMore: boolean; }; } + +/** A single trending creator entry (issue #1016). */ +export interface TrendingCreatorEntry { + rank: number; + userId: string; + username: string | null; + displayName: string | null; + stellarAddress: string; + imageUrl: string | null; + bio: string | null; + totalTipsStroops: string; + tipCount: number; +} + +/** Trending creators response (issue #1016). */ +export interface TrendingCreatorsResponse { + data: TrendingCreatorEntry[]; + pagination: { + limit: number; + offset: number; + total: number; + hasMore: boolean; + }; + window: string; +}