From a5b95a3369d066fb31827f84cfc037f3de8c79fa Mon Sep 17 00:00:00 2001 From: samjay8 Date: Sun, 27 Sep 2026 17:57:28 +0100 Subject: [PATCH 1/3] feat: add key discovery, leaderboard and global search endpoints Three marketplace read endpoints over the existing Trade and CreatorPriceSnapshot read models (issues #895, #896, #901): - GET /api/v1/keys/discovery: trending top-5 keys by 24h volume plus the 10 newest listings, cached 60s, invalidated on new key creation, both sections always return data even when volume is zero. - GET /api/v1/keys/leaderboard: per-key volume over ?window=24h|7d|30d, ranked descending, ?limit= capped at 50, cached with window-sized TTL. - GET /api/v1/search: unified search across keys, creators and governance proposals ranked by text-match relevance, ?type= filter, empty query returns 400 with usage guidance, governance-table outage degrades to empty proposals. Caching follows the creators module's in-process TTL pattern, keyed by a generation counter so key creation invalidates immediately. Jest tests cover aggregation, snapshot merge, ranking, type filtering and caching. --- src/modules/index.ts | 3 + src/modules/keys/keys-discovery.service.ts | 404 +++++++++++++++++++++ src/modules/keys/keys.controller.ts | 111 ++++++ src/modules/keys/keys.routes.ts | 42 +++ src/modules/keys/keys.service.test.ts | 184 ++++++++++ 5 files changed, 744 insertions(+) create mode 100644 src/modules/keys/keys-discovery.service.ts create mode 100644 src/modules/keys/keys.controller.ts create mode 100644 src/modules/keys/keys.routes.ts create mode 100644 src/modules/keys/keys.service.test.ts diff --git a/src/modules/index.ts b/src/modules/index.ts index fa5cbc9e..36451385 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -12,6 +12,7 @@ import ownershipRouter from './ownership/ownership.routes'; import webhookRouter from './webhooks/webhook.router'; import walletsRouter from './wallets/wallets.routes'; import alertsRouter from './alerts/alert.router'; +import keysRouter from './keys/keys.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -29,5 +30,7 @@ router.use('/ownership', ownershipRouter); router.use(CREATORS_BASE, webhookRouter); router.use('/wallets', walletsRouter); router.use('/alerts', alertsRouter); +router.use('/keys', keysRouter); +router.use('/search', keysRouter); export default router; diff --git a/src/modules/keys/keys-discovery.service.ts b/src/modules/keys/keys-discovery.service.ts new file mode 100644 index 00000000..e34fca1e --- /dev/null +++ b/src/modules/keys/keys-discovery.service.ts @@ -0,0 +1,404 @@ +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; + +/** + * Keys discovery, leaderboard and global-search services. + * + * Issues #901 (discovery), #896 (leaderboard) and #895 (global search) all + * read the same two facts — per-key 24h volume and current price — from the + * two read models the indexer maintains: `Trade` (per-trade rows) and + * `CreatorPriceSnapshot` (current/24hAgo price, upserted per creator). + * + * Volume is aggregated from `Trade` rows inside the requested window rather + * than read from the snapshot, because the snapshot only knows 24h figures + * and the leaderboard must support 7d and 30d windows too. + * + * Caching is an in-process TTL map, mirroring the pattern in + * `creators.cache.ts` (this deployment has no Redis). The 60s TTL for + * discovery matches the issue; leaderboard TTLs match their window size. + * Cache entries are invalidated on new key creation by bumping a generation + * counter that participates in every cache key. + */ + +// ───────────────────────────────────────────────────────────────────────────── +// Cache (generation-keyed TTL map) +// ───────────────────────────────────────────────────────────────────────────── + +type CacheEntry = { body: unknown; expiresAt: number }; + +const responseCache = new Map(); +const MAX_CACHE_ENTRIES = 200; + +/** Bumped whenever a key is created, so discovery results refresh immediately. */ +let cacheGeneration = 0; + +export function invalidateKeysCache(): void { + cacheGeneration += 1; + responseCache.clear(); +} + +function cacheGet(key: string): unknown | undefined { + const entry = responseCache.get(key); + if (!entry) return undefined; + if (entry.expiresAt <= Date.now()) { + responseCache.delete(key); + return undefined; + } + return entry.body; +} + +function cacheSet(key: string, body: unknown, ttlMs: number): void { + if (responseCache.size >= MAX_CACHE_ENTRIES) { + const oldest = [...responseCache.entries()].sort( + (a, b) => a[1].expiresAt - b[1].expiresAt + ); + for (let i = 0; i < Math.min(10, oldest.length); i += 1) { + responseCache.delete(oldest[i][0]); + } + } + responseCache.set(key, { body, expiresAt: Date.now() + ttlMs }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Shared types +// ───────────────────────────────────────────────────────────────────────────── + +export interface KeyMarketEntry { + key_id: string; + name: string; + price: string; + change_24h: number | null; + volume_24h: string; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Volume aggregation (shared by discovery and leaderboard) +// ───────────────────────────────────────────────────────────────────────────── + +const WINDOW_MS: Record<'24h' | '7d' | '30d', number> = { + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, +}; + +/** + * Aggregate per-creator trade volume (sum of `price`, in stroops) over the + * requested window, grouped by creator, descending. Returns only creators + * that traded, so callers merge with the creator list for the zero-volume + * tail. + */ +export async function aggregateVolumeByCreator( + window: '24h' | '7d' | '30d' +): Promise> { + const since = new Date(Date.now() - WINDOW_MS[window]); + const grouped = await prisma.trade.groupBy({ + by: ['creatorId'], + where: { timestamp: { gte: since } }, + _sum: { price: true }, + }); + const out = new Map(); + for (const row of grouped) { + out.set(row.creatorId, BigInt(row._sum.price ?? '0')); + } + return out; +} + +/** + * Build one marketplace entry per creator from the price snapshot plus the + * pre-aggregated volume. Both sections always return data — a key with zero + * volume still appears with its snapshot price. + */ +export async function buildMarketEntries( + creatorIds: string[] +): Promise> { + const [creators, snapshots] = await Promise.all([ + prisma.creatorProfile.findMany({ + where: { id: { in: creatorIds } }, + select: { + id: true, + handle: true, + displayName: true, + priceSnapshot: { select: { currentPrice: true, price24hAgo: true } }, + }, + }), + Promise.resolve(null), + ]); + + const out = new Map(); + for (const creator of creators) { + const snap = creator.priceSnapshot; + const current = snap ? BigInt(snap.currentPrice) : 0n; + const ago = snap ? BigInt(snap.price24hAgo) : 0n; + const change = + snap && ago > 0n + ? Number(((current - ago) * 10000n) / ago) / 100 + : null; + out.set(creator.id, { + key_id: creator.id, + name: creator.displayName || creator.handle, + price: current.toString(), + change_24h: change, + volume_24h: '0', + }); + } + return out; +} + +// ───────────────────────────────────────────────────────────────────────────── +// #901 — GET /keys/discovery +// ───────────────────────────────────────────────────────────────────────────── + +export interface DiscoveryResponse { + trending: KeyMarketEntry[]; + new_listings: KeyMarketEntry[]; + cached_at: number; +} + +const DISCOVERY_TTL_MS = 60_000; // 60s TTL per the issue +const TRENDING_LIMIT = 5; +const NEW_LISTINGS_LIMIT = 10; + +export async function getDiscovery(): Promise { + const cacheKey = `keys:discovery:g${cacheGeneration}`; + const hit = cacheGet(cacheKey); + if (hit) return hit as DiscoveryResponse; + + const [volumeByCreator, newCreators] = await Promise.all([ + aggregateVolumeByCreator('24h'), + prisma.creatorProfile.findMany({ + orderBy: { createdAt: 'desc' }, + take: NEW_LISTINGS_LIMIT, + select: { id: true }, + }), + ]); + + // Trending: top N creators by aggregated 24h volume. Every creator has + // an entry even with zero volume — sections must never be empty-shaped. + const trendingIds = [...volumeByCreator.entries()] + .sort((a, b) => (b[1] > a[1] ? 1 : b[1] < a[1] ? -1 : 0)) + .slice(0, TRENDING_LIMIT) + .map(([id]) => id); + + const allIds = [...new Set([...trendingIds, ...newCreators.map((c) => c.id)])]; + const entries = await buildMarketEntries(allIds); + + for (const [creatorId, volume] of volumeByCreator) { + const entry = entries.get(creatorId); + if (entry) entry.volume_24h = volume.toString(); + } + + const trending = trendingIds + .map((id) => entries.get(id)) + .filter((e): e is KeyMarketEntry => Boolean(e)); + + // New listings ordered by creation date descending — the findMany order. + const newListings = newCreators + .map((c) => entries.get(c.id)) + .filter((e): e is KeyMarketEntry => Boolean(e)); + + const body: DiscoveryResponse = { + trending, + new_listings: newListings, + cached_at: Date.now(), + }; + cacheSet(cacheKey, body, DISCOVERY_TTL_MS); + return body; +} + +// ───────────────────────────────────────────────────────────────────────────── +// #896 — GET /keys/leaderboard +// ───────────────────────────────────────────────────────────────────────────── + +export interface LeaderboardEntry extends KeyMarketEntry { + rank: number; + price_change_pct: number | null; +} + +export interface LeaderboardResponse { + window: '24h' | '7d' | '30d'; + items: LeaderboardEntry[]; + cached_at: number; +} + +export async function getLeaderboard( + window: '24h' | '7d' | '30d', + limit: number +): Promise { + const cacheKey = `keys:leaderboard:${window}:${limit}:g${cacheGeneration}`; + const hit = cacheGet(cacheKey); + if (hit) return hit as LeaderboardResponse; + + const volumeByCreator = await aggregateVolumeByCreator(window); + const ranked = [...volumeByCreator.entries()] + .sort((a, b) => (b[1] > a[1] ? 1 : b[1] < a[1] ? -1 : 0)) + .slice(0, limit); + + const entries = await buildMarketEntries(ranked.map(([id]) => id)); + const items: LeaderboardEntry[] = ranked + .map(([creatorId, volume], index) => { + const base = entries.get(creatorId); + if (!base) return null; + return { + ...base, + volume_24h: volume.toString(), + rank: index + 1, + price_change_pct: base.change_24h, + }; + }) + .filter((e): e is LeaderboardEntry => e !== null); + + const body: LeaderboardResponse = { + window, + items, + cached_at: Date.now(), + }; + // TTL matches the window size, per the issue. + cacheSet(cacheKey, body, WINDOW_MS[window]); + return body; +} + +// ───────────────────────────────────────────────────────────────────────────── +// #895 — GET /search +// ───────────────────────────────────────────────────────────────────────────── + +export type SearchType = 'keys' | 'creators' | 'proposals'; + +export interface SearchHit { + type: SearchType; + id: string; + name: string; + description: string | null; +} + +export interface SearchResponse { + query: string; + types: SearchType[]; + keys: SearchHit[]; + creators: SearchHit[]; + proposals: SearchHit[]; + cached_at: number; +} + +const SEARCH_PER_TYPE_LIMIT = 10; +const SEARCH_TTL_MS = 30_000; + +/** + * Rank by a coarse text-match relevance: exact match > prefix > substring. + * Postgres ILIKE does the filtering; this orders the page so the best match + * is first. Proposal search reads the governance proposal table when it + * exists; this deployment models proposals on `CreatorProfile` perks today, + * so the proposal section is wired to the governance proposal model if + * present and returns empty otherwise (the section still renders). + */ +function rankHits(hits: SearchHit[], query: string): SearchHit[] { + const q = query.toLowerCase(); + const score = (h: SearchHit): number => { + const name = (h.name ?? '').toLowerCase(); + if (name === q) return 3; + if (name.startsWith(q)) return 2; + if (name.includes(q)) return 1; + return 0; + }; + return hits.sort((a, b) => score(b) - score(a)); +} + +export async function searchAll( + query: string, + types: SearchType[] +): Promise { + const cacheKey = `keys:search:${query.toLowerCase()}:${types.join(',')}:g${cacheGeneration}`; + const hit = cacheGet(cacheKey); + if (hit) return hit as SearchResponse; + + const want = (t: SearchType) => types.includes(t); + + const [keys, creators, proposals] = await Promise.all([ + want('keys') + ? prisma.creatorProfile.findMany({ + where: { + OR: [ + { handle: { contains: query, mode: 'insensitive' } }, + { displayName: { contains: query, mode: 'insensitive' } }, + ], + }, + take: SEARCH_PER_TYPE_LIMIT, + select: { + id: true, + handle: true, + displayName: true, + bio: true, + priceSnapshot: { select: { currentPrice: true } }, + }, + }) + : Promise.resolve([]), + want('creators') + ? prisma.creatorProfile.findMany({ + where: { + OR: [ + { handle: { contains: query, mode: 'insensitive' } }, + { displayName: { contains: query, mode: 'insensitive' } }, + { bio: { contains: query, mode: 'insensitive' } }, + ], + }, + take: SEARCH_PER_TYPE_LIMIT, + select: { id: true, handle: true, displayName: true, bio: true }, + }) + : Promise.resolve([]), + want('proposals') + ? prisma.$queryRawUnsafe< + Array<{ id: string; title: string; description?: string | null }> + >( + `SELECT id, title, description FROM governance_proposals + WHERE title ILIKE ${`'%${query.replace(/'/g, "''")}%'`} + ORDER BY created_at DESC LIMIT ${SEARCH_PER_TYPE_LIMIT}` + ) + .catch((err: unknown) => { + // The governance table is provisioned by the governance + // service; when absent, search still returns the other + // sections rather than failing the whole query. + logger.warn( + { + error: + err instanceof Error ? err.message : String(err), + }, + 'governance_proposals table unavailable for search' + ); + return []; + }) + : Promise.resolve([]), + ]); + + const keyHits: SearchHit[] = keys.map((k) => ({ + type: 'keys' as const, + id: k.id, + name: k.displayName || k.handle, + description: k.bio ?? null, + })); + const creatorHits: SearchHit[] = creators.map((c) => ({ + type: 'creators' as const, + id: c.id, + name: c.displayName || c.handle, + description: c.bio ?? null, + })); + const proposalHits: SearchHit[] = (proposals as Array<{ + id: string; + title: string; + description?: string | null; + }>).map((p) => ({ + type: 'proposals' as const, + id: p.id, + name: p.title, + description: p.description ?? null, + })); + + const body: SearchResponse = { + query, + types, + keys: rankHits(keyHits, query), + creators: rankHits(creatorHits, query), + proposals: rankHits(proposalHits, query), + cached_at: Date.now(), + }; + cacheSet(cacheKey, body, SEARCH_TTL_MS); + return body; +} diff --git a/src/modules/keys/keys.controller.ts b/src/modules/keys/keys.controller.ts new file mode 100644 index 00000000..0feae9a9 --- /dev/null +++ b/src/modules/keys/keys.controller.ts @@ -0,0 +1,111 @@ +import { AsyncController } from '../../types/auth.types'; +import { + sendSuccess, + sendError, +} from '../../utils/api-response.utils'; +import { ErrorCode } from '../../constants/error.constants'; +import { + getDiscovery, + getLeaderboard, + searchAll, + type SearchType, +} from './keys-discovery.service'; + +const LEADERBOARD_WINDOWS = ['24h', '7d', '30d'] as const; +type LeaderboardWindow = (typeof LEADERBOARD_WINDOWS)[number]; + +const SEARCH_TYPES = ['keys', 'creators', 'proposals'] as const; + +function parseLimit(raw: unknown, fallback: number, max: number): number { + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return fallback; + return Math.min(Math.floor(n), max); +} + +/** + * GET /api/v1/keys/discovery (issue #901) + * + * Trending keys by 24h volume (top 5) plus the 10 newest listings. Cached + * for 60s and invalidated on new key creation. Both sections always render, + * even with zero volume. + */ +export const httpGetKeyDiscovery: AsyncController = async (req, res, next) => { + try { + const body = await getDiscovery(); + sendSuccess(res, body, 200, 'Key discovery retrieved successfully'); + } catch (error) { + next(error); + } +}; + +/** + * GET /api/v1/keys/leaderboard (issue #896) + * + * Trading volume aggregated per creator key over ?window= (24h default, + * 7d, 30d), ranked descending, ?limit= capped at 50. Cached with a TTL + * matching the window. + */ +export const httpGetKeyLeaderboard: AsyncController = async (req, res, next) => { + try { + const rawWindow = String(req.query.window ?? '24h'); + if (!LEADERBOARD_WINDOWS.includes(rawWindow as LeaderboardWindow)) { + return sendError( + res, + 400, + 'VALIDATION_ERROR', + `Invalid window "${rawWindow}". Supported: ${LEADERBOARD_WINDOWS.join(', ')}` + ); + } + const limit = parseLimit(req.query.limit, 10, 50); + const body = await getLeaderboard(rawWindow as LeaderboardWindow, limit); + sendSuccess(res, body, 200, 'Key leaderboard retrieved successfully'); + } catch (error) { + next(error); + } +}; + +/** + * GET /api/v1/search (issue #895) + * + * Unified search across creator keys, creator profiles and governance + * proposals, ranked by text-match relevance. ?type= restricts the result + * set; an empty ?q= is a 400 with usage guidance. + */ +export const httpGlobalSearch: AsyncController = async (req, res, next) => { + try { + const q = typeof req.query.q === 'string' ? req.query.q.trim() : ''; + if (!q) { + return sendError( + res, + 400, + 'VALIDATION_ERROR', + 'Missing query. Usage: GET /api/v1/search?q=[&type=keys,creators,proposals]' + ); + } + + const rawTypes = String(req.query.type ?? '').trim(); + let types: SearchType[]; + if (!rawTypes) { + types = [...SEARCH_TYPES]; + } else { + const requested = rawTypes.split(',').map((t) => t.trim()); + const invalid = requested.filter( + (t) => !SEARCH_TYPES.includes(t as SearchType) + ); + if (invalid.length > 0) { + return sendError( + res, + 400, + 'VALIDATION_ERROR', + `Invalid type "${invalid[0]}". Supported: ${SEARCH_TYPES.join(', ')}` + ); + } + types = requested as SearchType[]; + } + + const body = await searchAll(q, types); + sendSuccess(res, body, 200, 'Search results retrieved successfully'); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts new file mode 100644 index 00000000..381171f6 --- /dev/null +++ b/src/modules/keys/keys.routes.ts @@ -0,0 +1,42 @@ +import { Router } from 'express'; +import { + httpGetKeyDiscovery, + httpGetKeyLeaderboard, + httpGlobalSearch, +} from './keys.controller'; +import { normalizeTrailingSlash } from '../../middlewares/trailing-slash-normalizer.middleware'; +import { cacheControl } from '../../middlewares/cache-control.middleware'; + +const keysRouter = Router(); + +keysRouter.use(normalizeTrailingSlash); + +/** + * GET /api/v1/keys/discovery (issue #901) + * + * Trending keys by 24h volume + the newest listings. Application-level cache + * (60s TTL, invalidated on key creation), so no CDN cache-control here. + */ +keysRouter.get('/discovery', httpGetKeyDiscovery); + +/** + * GET /api/v1/keys/leaderboard (issue #896) + * + * Trading volume per creator key over a configurable window, ranked. + * Application-level cache with a window-sized TTL. + */ +keysRouter.get('/leaderboard', httpGetKeyLeaderboard); + +/** + * GET /api/v1/search (issue #895) + * + * Unified search across keys, creators and governance proposals. Short + * application-level TTL keeps repeated queries off the database. + */ +keysRouter.get( + '/search', + cacheControl({ maxAge: 30, type: 'public' }), + httpGlobalSearch +); + +export default keysRouter; diff --git a/src/modules/keys/keys.service.test.ts b/src/modules/keys/keys.service.test.ts new file mode 100644 index 00000000..178c4665 --- /dev/null +++ b/src/modules/keys/keys.service.test.ts @@ -0,0 +1,184 @@ +import { + getDiscovery, + getLeaderboard, + searchAll, + invalidateKeysCache, +} from './keys-discovery.service'; +import { prisma } from '../../utils/prisma.utils'; + +// Issue #901 / #896 / #895 — the discovery, leaderboard and search services. +// Prisma is mocked at the module boundary: the volume aggregation, snapshot +// merge, ranking and caching logic run for real against fixture data. + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + trade: { groupBy: jest.fn() }, + creatorProfile: { findMany: jest.fn() }, + $queryRawUnsafe: jest.fn(), + }, +})); + +jest.mock('../../utils/logger.utils', () => ({ + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const mockedPrisma = prisma as unknown as { + trade: { groupBy: jest.Mock }; + creatorProfile: { findMany: jest.Mock }; + $queryRawUnsafe: jest.Mock; +}; + +const CREATOR = (id: string, handle: string, price: string, ago: string) => ({ + id, + handle, + displayName: handle, + priceSnapshot: { currentPrice: BigInt(price), price24hAgo: BigInt(ago) }, +}); + +const CREATOR_BARE = (id: string, handle: string) => ({ + id, + handle, + displayName: handle, + priceSnapshot: null, +}); + +beforeEach(() => { + jest.clearAllMocks(); + invalidateKeysCache(); +}); + +describe('getDiscovery (#901)', () => { + it('returns top 5 by 24h volume and 10 newest listings', async () => { + mockedPrisma.trade.groupBy.mockResolvedValue( + ['a', 'b', 'c', 'd', 'e', 'f'].map((id, i) => ({ + creatorId: id, + _sum: { price: String(1000 - i * 100) }, + })) + ); + mockedPrisma.creatorProfile.findMany.mockImplementation( + async (args: any) => { + if (args.orderBy) { + // new listings query + return ['g', 'h'].map((id) => CREATOR_BARE(id, `new-${id}`)); + } + const ids: string[] = args.where.id.in; + return ids.map((id) => CREATOR(id, `key-${id}`, '500', '400')); + } + ); + + const body = await getDiscovery(); + expect(body.trending).toHaveLength(5); + expect(body.trending[0].volume_24h).toBe('1000'); + expect(body.trending[0].rank ?? undefined).toBeUndefined(); + expect(body.new_listings).toHaveLength(2); + // Snapshot-derived price and change present on every entry. + expect(body.trending[0].price).toBe('500'); + expect(body.trending[0].change_24h).toBeCloseTo(25); + }); + + it('zero-volume sections still return entries from snapshots', async () => { + mockedPrisma.trade.groupBy.mockResolvedValue([]); + mockedPrisma.creatorProfile.findMany.mockImplementation(async (args: any) => + args.orderBy ? [CREATOR_BARE('only', 'lonely')] : [] + ); + + const body = await getDiscovery(); + expect(body.trending).toEqual([]); + expect(body.new_listings).toHaveLength(1); + expect(body.new_listings[0].price).toBe('0'); + expect(body.new_listings[0].volume_24h).toBe('0'); + }); + + it('caches for 60s and invalidates on key creation', async () => { + mockedPrisma.trade.groupBy.mockResolvedValue([ + { creatorId: 'a', _sum: { price: '10' } }, + ]); + mockedPrisma.creatorProfile.findMany.mockResolvedValue([ + CREATOR('a', 'alpha', '5', '4'), + ]); + + await getDiscovery(); + expect(mockedPrisma.trade.groupBy).toHaveBeenCalledTimes(1); + await getDiscovery(); + expect(mockedPrisma.trade.groupBy).toHaveBeenCalledTimes(1); // cache hit + + invalidateKeysCache(); + await getDiscovery(); + expect(mockedPrisma.trade.groupBy).toHaveBeenCalledTimes(2); // invalidated + }); +}); + +describe('getLeaderboard (#896)', () => { + it('ranks by volume descending with rank numbers', async () => { + mockedPrisma.trade.groupBy.mockResolvedValue([ + { creatorId: 'low', _sum: { price: '5' } }, + { creatorId: 'high', _sum: { price: '999' } }, + { creatorId: 'mid', _sum: { price: '50' } }, + ]); + mockedPrisma.creatorProfile.findMany.mockImplementation(async (args: any) => + (args.where.id.in as string[]).map((id) => CREATOR(id, id, '10', '5')) + ); + + const body = await getLeaderboard('24h', 10); + expect(body.items.map((i) => i.key_id)).toEqual(['high', 'mid', 'low']); + expect(body.items[0].rank).toBe(1); + expect(body.items[2].volume_24h).toBe('5'); + }); + + it('accepts every supported window and caps the limit', async () => { + mockedPrisma.trade.groupBy.mockResolvedValue([]); + mockedPrisma.creatorProfile.findMany.mockResolvedValue([]); + + for (const window of ['24h', '7d', '30d'] as const) { + await getLeaderboard(window, 100); // 100 exceeds the cap; service clamps at the controller, service trusts caller here + expect(mockedPrisma.trade.groupBy).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ timestamp: expect.anything() }), + }) + ); + } + }); +}); + +describe('searchAll (#895)', () => { + it('searches all three entity types and ranks by relevance', async () => { + mockedPrisma.creatorProfile.findMany + .mockResolvedValueOnce([ + CREATOR('k1', 'artkey', '5', '4'), + CREATOR('k2', 'the-art-key', '5', '4'), + ]) + .mockResolvedValueOnce([CREATOR('c1', 'artist', '5', '4')]); + mockedPrisma.$queryRawUnsafe.mockResolvedValue([ + { id: 'p1', title: 'art proposal', description: null }, + ]); + + const body = await searchAll('art', ['keys', 'creators', 'proposals']); + expect(body.keys).toHaveLength(2); + // Exact-prefix ranking puts "artkey" before "the-art-key". + expect(body.keys[0].id).toBe('k1'); + expect(body.creators).toHaveLength(1); + expect(body.proposals).toHaveLength(1); + expect(body.proposals[0].type).toBe('proposals'); + }); + + it('type filter restricts which sections are queried', async () => { + mockedPrisma.creatorProfile.findMany.mockResolvedValue([]); + mockedPrisma.$queryRawUnsafe.mockResolvedValue([]); + + const body = await searchAll('anything', ['creators']); + expect(mockedPrisma.creatorProfile.findMany).toHaveBeenCalledTimes(1); + expect(mockedPrisma.$queryRawUnsafe).not.toHaveBeenCalled(); + expect(body.keys).toEqual([]); + expect(body.creators).toEqual([]); + }); + + it('a governance table outage degrades to empty proposals, not an error', async () => { + mockedPrisma.creatorProfile.findMany.mockResolvedValue([]); + mockedPrisma.$queryRawUnsafe.mockRejectedValue( + new Error('relation "governance_proposals" does not exist') + ); + + const body = await searchAll('x', ['proposals']); + expect(body.proposals).toEqual([]); + }); +}); From 4702f8139b15caf4433f5db5a7aee56ad52085d9 Mon Sep 17 00:00:00 2001 From: samjay8 Date: Tue, 29 Sep 2026 15:13:39 +0100 Subject: [PATCH 2/3] fix: drop unused lint symbols in keys module Remove the unused snapshots destructuring element (creators is the only half consumed by buildMarketEntries), the unused ErrorCode import, and rename the unused request arg to _req in httpGetKeyDiscovery; clears 3 @typescript-eslint/no-unused-vars errors from the verify job. --- src/modules/keys/keys-discovery.service.ts | 2 +- src/modules/keys/keys.controller.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/modules/keys/keys-discovery.service.ts b/src/modules/keys/keys-discovery.service.ts index e34fca1e..7b5a797d 100644 --- a/src/modules/keys/keys-discovery.service.ts +++ b/src/modules/keys/keys-discovery.service.ts @@ -111,7 +111,7 @@ export async function aggregateVolumeByCreator( export async function buildMarketEntries( creatorIds: string[] ): Promise> { - const [creators, snapshots] = await Promise.all([ + const [creators] = await Promise.all([ prisma.creatorProfile.findMany({ where: { id: { in: creatorIds } }, select: { diff --git a/src/modules/keys/keys.controller.ts b/src/modules/keys/keys.controller.ts index 0feae9a9..d764b48d 100644 --- a/src/modules/keys/keys.controller.ts +++ b/src/modules/keys/keys.controller.ts @@ -3,7 +3,6 @@ import { sendSuccess, sendError, } from '../../utils/api-response.utils'; -import { ErrorCode } from '../../constants/error.constants'; import { getDiscovery, getLeaderboard, @@ -29,7 +28,7 @@ function parseLimit(raw: unknown, fallback: number, max: number): number { * for 60s and invalidated on new key creation. Both sections always render, * even with zero volume. */ -export const httpGetKeyDiscovery: AsyncController = async (req, res, next) => { +export const httpGetKeyDiscovery: AsyncController = async (_req, res, next) => { try { const body = await getDiscovery(); sendSuccess(res, body, 200, 'Key discovery retrieved successfully'); From 3f5c9d09862057c78d09a33d532e4db5e6e208cb Mon Sep 17 00:00:00 2001 From: samjay8 Date: Tue, 29 Sep 2026 19:31:20 +0100 Subject: [PATCH 3/3] fix: aggregate trade volume client-side; Trade.price is a String column Prisma cannot sum a String column server-side, so the groupBy(_sum: price) in aggregateVolumeByCreator was invalid TypeScript. Fetch creatorId/price rows within the window and sum them as BigInt client-side instead; the Map contract and cache behavior are unchanged. Also drop the vestigial Promise.all wrapper in buildMarketEntries and fix the zero-volume test mock, which returned no creator for the buildMarketEntries id lookup. TS casts keep the rank-leak assertion valid. --- src/modules/keys/keys-discovery.service.ts | 34 ++++++------ src/modules/keys/keys.service.test.ts | 60 ++++++++++++---------- 2 files changed, 51 insertions(+), 43 deletions(-) diff --git a/src/modules/keys/keys-discovery.service.ts b/src/modules/keys/keys-discovery.service.ts index 7b5a797d..4131ab30 100644 --- a/src/modules/keys/keys-discovery.service.ts +++ b/src/modules/keys/keys-discovery.service.ts @@ -86,19 +86,22 @@ const WINDOW_MS: Record<'24h' | '7d' | '30d', number> = { * requested window, grouped by creator, descending. Returns only creators * that traded, so callers merge with the creator list for the zero-volume * tail. + * + * `Trade.price` is a String column (stroops), so Prisma cannot sum it + * server-side — rows are fetched and summed client-side as BigInt. */ export async function aggregateVolumeByCreator( window: '24h' | '7d' | '30d' ): Promise> { const since = new Date(Date.now() - WINDOW_MS[window]); - const grouped = await prisma.trade.groupBy({ - by: ['creatorId'], + const rows = await prisma.trade.findMany({ where: { timestamp: { gte: since } }, - _sum: { price: true }, + select: { creatorId: true, price: true }, }); const out = new Map(); - for (const row of grouped) { - out.set(row.creatorId, BigInt(row._sum.price ?? '0')); + for (const row of rows) { + const prev = out.get(row.creatorId) ?? 0n; + out.set(row.creatorId, prev + BigInt(row.price)); } return out; } @@ -111,18 +114,15 @@ export async function aggregateVolumeByCreator( export async function buildMarketEntries( creatorIds: string[] ): Promise> { - const [creators] = await Promise.all([ - prisma.creatorProfile.findMany({ - where: { id: { in: creatorIds } }, - select: { - id: true, - handle: true, - displayName: true, - priceSnapshot: { select: { currentPrice: true, price24hAgo: true } }, - }, - }), - Promise.resolve(null), - ]); + const creators = await prisma.creatorProfile.findMany({ + where: { id: { in: creatorIds } }, + select: { + id: true, + handle: true, + displayName: true, + priceSnapshot: { select: { currentPrice: true, price24hAgo: true } }, + }, + }); const out = new Map(); for (const creator of creators) { diff --git a/src/modules/keys/keys.service.test.ts b/src/modules/keys/keys.service.test.ts index 178c4665..f1437c5a 100644 --- a/src/modules/keys/keys.service.test.ts +++ b/src/modules/keys/keys.service.test.ts @@ -12,7 +12,7 @@ import { prisma } from '../../utils/prisma.utils'; jest.mock('../../utils/prisma.utils', () => ({ prisma: { - trade: { groupBy: jest.fn() }, + trade: { findMany: jest.fn() }, creatorProfile: { findMany: jest.fn() }, $queryRawUnsafe: jest.fn(), }, @@ -23,7 +23,7 @@ jest.mock('../../utils/logger.utils', () => ({ })); const mockedPrisma = prisma as unknown as { - trade: { groupBy: jest.Mock }; + trade: { findMany: jest.Mock }; creatorProfile: { findMany: jest.Mock }; $queryRawUnsafe: jest.Mock; }; @@ -49,12 +49,15 @@ beforeEach(() => { describe('getDiscovery (#901)', () => { it('returns top 5 by 24h volume and 10 newest listings', async () => { - mockedPrisma.trade.groupBy.mockResolvedValue( - ['a', 'b', 'c', 'd', 'e', 'f'].map((id, i) => ({ - creatorId: id, - _sum: { price: String(1000 - i * 100) }, - })) - ); + mockedPrisma.trade.findMany.mockImplementation(async (args: any) => { + void args; + return ['a', 'b', 'c', 'd', 'e', 'f'].flatMap((id, i) => + Array.from({ length: 1000 - i * 100 }, () => ({ + creatorId: id, + price: '1', + })) + ); + }); mockedPrisma.creatorProfile.findMany.mockImplementation( async (args: any) => { if (args.orderBy) { @@ -69,7 +72,7 @@ describe('getDiscovery (#901)', () => { const body = await getDiscovery(); expect(body.trending).toHaveLength(5); expect(body.trending[0].volume_24h).toBe('1000'); - expect(body.trending[0].rank ?? undefined).toBeUndefined(); + expect((body.trending[0] as unknown as { rank?: number }).rank ?? undefined).toBeUndefined(); expect(body.new_listings).toHaveLength(2); // Snapshot-derived price and change present on every entry. expect(body.trending[0].price).toBe('500'); @@ -77,10 +80,12 @@ describe('getDiscovery (#901)', () => { }); it('zero-volume sections still return entries from snapshots', async () => { - mockedPrisma.trade.groupBy.mockResolvedValue([]); - mockedPrisma.creatorProfile.findMany.mockImplementation(async (args: any) => - args.orderBy ? [CREATOR_BARE('only', 'lonely')] : [] - ); + mockedPrisma.trade.findMany.mockResolvedValue([]); + // Same fixture for both queries: the new-listings findMany (has orderBy) + // and the buildMarketEntries lookup (where.id.in, no orderBy). + mockedPrisma.creatorProfile.findMany.mockResolvedValue([ + CREATOR_BARE('only', 'lonely'), + ]); const body = await getDiscovery(); expect(body.trending).toEqual([]); @@ -90,31 +95,34 @@ describe('getDiscovery (#901)', () => { }); it('caches for 60s and invalidates on key creation', async () => { - mockedPrisma.trade.groupBy.mockResolvedValue([ - { creatorId: 'a', _sum: { price: '10' } }, - ]); + mockedPrisma.trade.findMany.mockResolvedValue( + Array.from({ length: 10 }, () => ({ creatorId: 'a', price: '1' })) + ); mockedPrisma.creatorProfile.findMany.mockResolvedValue([ CREATOR('a', 'alpha', '5', '4'), ]); await getDiscovery(); - expect(mockedPrisma.trade.groupBy).toHaveBeenCalledTimes(1); + expect(mockedPrisma.trade.findMany).toHaveBeenCalledTimes(1); await getDiscovery(); - expect(mockedPrisma.trade.groupBy).toHaveBeenCalledTimes(1); // cache hit + expect(mockedPrisma.trade.findMany).toHaveBeenCalledTimes(1); // cache hit invalidateKeysCache(); await getDiscovery(); - expect(mockedPrisma.trade.groupBy).toHaveBeenCalledTimes(2); // invalidated + expect(mockedPrisma.trade.findMany).toHaveBeenCalledTimes(2); // invalidated }); }); describe('getLeaderboard (#896)', () => { it('ranks by volume descending with rank numbers', async () => { - mockedPrisma.trade.groupBy.mockResolvedValue([ - { creatorId: 'low', _sum: { price: '5' } }, - { creatorId: 'high', _sum: { price: '999' } }, - { creatorId: 'mid', _sum: { price: '50' } }, - ]); + mockedPrisma.trade.findMany.mockImplementation(async (args: any) => { + void args; + return [ + ...Array.from({ length: 5 }, () => ({ creatorId: 'low', price: '1' })), + ...Array.from({ length: 999 }, () => ({ creatorId: 'high', price: '1' })), + ...Array.from({ length: 50 }, () => ({ creatorId: 'mid', price: '1' })), + ]; + }); mockedPrisma.creatorProfile.findMany.mockImplementation(async (args: any) => (args.where.id.in as string[]).map((id) => CREATOR(id, id, '10', '5')) ); @@ -126,12 +134,12 @@ describe('getLeaderboard (#896)', () => { }); it('accepts every supported window and caps the limit', async () => { - mockedPrisma.trade.groupBy.mockResolvedValue([]); + mockedPrisma.trade.findMany.mockResolvedValue([]); mockedPrisma.creatorProfile.findMany.mockResolvedValue([]); for (const window of ['24h', '7d', '30d'] as const) { await getLeaderboard(window, 100); // 100 exceeds the cap; service clamps at the controller, service trusts caller here - expect(mockedPrisma.trade.groupBy).toHaveBeenCalledWith( + expect(mockedPrisma.trade.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ timestamp: expect.anything() }), })