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
17 changes: 16 additions & 1 deletion backend/src/modules/search/search.controller.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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<void> {
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);
}
}
74 changes: 74 additions & 0 deletions backend/src/modules/search/search.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand All @@ -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: {
Expand Down Expand Up @@ -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' },
},
},
},
});
9 changes: 9 additions & 0 deletions backend/src/modules/search/search.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,12 @@ export const searchCreatorsQuerySchema = z.object({
});

export type SearchCreatorsQuery = z.infer<typeof searchCreatorsQuerySchema>;

/** 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<typeof trendingCreatorsQuerySchema>;
79 changes: 79 additions & 0 deletions backend/src/modules/search/search.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {
'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';
Expand Down Expand Up @@ -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<TrendingCreatorsResponse> {
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,
};
}
94 changes: 93 additions & 1 deletion backend/src/modules/search/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
},
}));
Expand Down Expand Up @@ -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();
Expand Down
25 changes: 25 additions & 0 deletions backend/src/modules/search/search.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}