diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f99f50..f91e530 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '9.0' +lockfileVersion: '6.0' settings: autoInstallPeers: true diff --git a/prisma/schema/creator.prisma b/prisma/schema/creator.prisma index c2becbc..3c73213 100644 --- a/prisma/schema/creator.prisma +++ b/prisma/schema/creator.prisma @@ -15,6 +15,7 @@ model CreatorProfile { user User @relation(fields: [userId], references: [id], onDelete: Cascade) priceSnapshot CreatorPriceSnapshot? + priceHistory CreatorPriceHistory[] posts CreatorPost[] } diff --git a/prisma/schema/price-history.prisma b/prisma/schema/price-history.prisma new file mode 100644 index 0000000..410cb90 --- /dev/null +++ b/prisma/schema/price-history.prisma @@ -0,0 +1,15 @@ +// prisma/schema/price-history.prisma +// Historical price data points written on every trade event. +// Enables computing price change over arbitrary time windows (1h, 24h, 7d, etc.). + +model CreatorPriceHistory { + id String @id @default(cuid()) + creatorId String + price BigInt + recordedAt DateTime @default(now()) + + creator CreatorProfile @relation(fields: [creatorId], references: [id], onDelete: Cascade) + + @@index([creatorId, recordedAt]) + @@map("creator_price_history") +} diff --git a/src/modules/creator/creator-detail-empty-social-links.integration.test.ts b/src/modules/creator/creator-detail-empty-social-links.integration.test.ts index 2b06b00..7788f2e 100644 --- a/src/modules/creator/creator-detail-empty-social-links.integration.test.ts +++ b/src/modules/creator/creator-detail-empty-social-links.integration.test.ts @@ -31,6 +31,8 @@ describe('creator detail integration - empty social links', () => { bio: 'Bio', avatarUrl: 'https://example.com/avatar.png', perks: [], + createdAt: new Date(), + updatedAt: new Date(), }); const result = await getCreatorProfile('creator-1'); diff --git a/src/modules/creator/creator-profile.service.test.ts b/src/modules/creator/creator-profile.service.test.ts index c2ac918..9e3a4f3 100644 --- a/src/modules/creator/creator-profile.service.test.ts +++ b/src/modules/creator/creator-profile.service.test.ts @@ -34,7 +34,11 @@ describe('getCreatorProfile', () => { avatarUrl: null, createdAt: null, updatedAt: null, + perks: [], links: [], + currentPrice: null, + price24hAgo: null, + priceChange24h: null, metadata: { source: 'placeholder', isProfileComplete: false, diff --git a/src/modules/creator/creator-profile.service.ts b/src/modules/creator/creator-profile.service.ts index 331f889..484a521 100644 --- a/src/modules/creator/creator-profile.service.ts +++ b/src/modules/creator/creator-profile.service.ts @@ -9,6 +9,7 @@ import { formatIsoTimestamp } from '../../utils/iso-timestamp.utils'; import { compute24hPriceChange } from '../../utils/price.utils'; import { normalizeSocialLinkUrl } from './creator-social-link-url.utils'; import { truncateString } from '../../utils/string-truncate.utils'; +import { computePriceChange } from '../../utils/price-change.utils'; const CREATOR_PROFILE_LIMITS = { displayName: 80, @@ -121,8 +122,17 @@ export async function getCreatorProfile( lastTradeAt: Date | null; } | null; + const ONE_DAY_MS = 86_400_000; let priceChange24h: number | null = null; - if (snapshot) { + + const computedChange = await computePriceChange( + profile.id, + ONE_DAY_MS, + prisma + ); + if (computedChange !== null) { + priceChange24h = computedChange; + } else if (snapshot) { priceChange24h = compute24hPriceChange( snapshot.currentPrice, snapshot.price24hAgo diff --git a/src/modules/creators/creator-list-item.mapper.test.ts b/src/modules/creators/creator-list-item.mapper.test.ts index 66bf37f..c5f3ad3 100644 --- a/src/modules/creators/creator-list-item.mapper.test.ts +++ b/src/modules/creators/creator-list-item.mapper.test.ts @@ -1,17 +1,30 @@ +jest.mock('../../utils/prisma.utils', () => ({ + prisma: {}, +})); + +jest.mock('../../utils/price-change.utils', () => ({ + computePriceChange: jest.fn().mockResolvedValue(null), +})); + import { mapCreatorListItem } from './creator-list-item.mapper'; import { createSeededCreatorFixture } from '../../utils/test/seeded-creator-fixtures.utils'; describe('mapCreatorListItem()', () => { - it('maps the public creator list item shape', () => { + it('maps the public creator list item shape', async () => { const input = createSeededCreatorFixture(1); - const result = mapCreatorListItem(input); + const result = await mapCreatorListItem(input); expect(result).toEqual({ id: 'creator-1', name: 'Creator 1', avatar: 'https://example.com/avatar-1.png', followers: 0, + createdAt: expect.any(String), + updatedAt: expect.any(String), + currentPrice: null, + price24hAgo: null, + priceChange24h: null, }); }); }); diff --git a/src/modules/creators/creator-list-item.mapper.ts b/src/modules/creators/creator-list-item.mapper.ts index 68f8c7b..3dcdb0a 100644 --- a/src/modules/creators/creator-list-item.mapper.ts +++ b/src/modules/creators/creator-list-item.mapper.ts @@ -3,6 +3,8 @@ import { requestContextStorage } from '../../utils/als.utils'; import { formatIsoTimestamp } from '../../utils/iso-timestamp.utils'; import { logger } from '../../utils/logger.utils'; import { safeRead } from '../../utils/safe-nested-read.utils'; +import { prisma } from '../../utils/prisma.utils'; +import { computePriceChange } from '../../utils/price-change.utils'; import { compute24hPriceChange } from '../../utils/price.utils'; /** @@ -83,12 +85,13 @@ function warnIfUnexpectedNullCreatorField( } /** - * Pure, dumb mapper from a full `CreatorProfile` to a `CreatorListItem`. - * No filtering, no business logic — deterministic and predictable. + * Maps a full `CreatorProfile` to a `CreatorListItem`. + * The price change is fetched from the price history table with a fallback + * to the snapshot-level 24h-ago data. */ -export const mapCreatorListItem = ( +export const mapCreatorListItem = async ( creator: CreatorProfile -): CreatorListItem => { +): Promise => { warnIfUnexpectedNullCreatorField(creator, 'displayName'); logIfFieldTypeMismatch(creator, 'id'); @@ -107,8 +110,17 @@ export const mapCreatorListItem = ( const currentPrice = snapshot?.currentPrice ?? null; const price24hAgo = snapshot?.price24hAgo ?? null; + const ONE_DAY_MS = 86_400_000; let priceChange24h: number | null = null; - if (currentPrice !== null && price24hAgo !== null) { + + const computedChange = await computePriceChange( + creator.id, + ONE_DAY_MS, + prisma + ); + if (computedChange !== null) { + priceChange24h = computedChange; + } else if (currentPrice !== null && price24hAgo !== null) { priceChange24h = compute24hPriceChange(currentPrice, price24hAgo); } diff --git a/src/modules/creators/creators.controllers.ts b/src/modules/creators/creators.controllers.ts index febe102..ef1b3b2 100644 --- a/src/modules/creators/creators.controllers.ts +++ b/src/modules/creators/creators.controllers.ts @@ -70,7 +70,7 @@ export const httpListCreators: AsyncController = async (req, res, next) => { // Fetch creators and total count const [creators, total] = await fetchCreatorList(validatedQuery); - const response: CreatorListResponse = serializeCreatorListResponse( + const response: CreatorListResponse = await serializeCreatorListResponse( creators, buildOffsetPaginationMeta({ limit: validatedQuery.limit, diff --git a/src/modules/creators/creators.serializers.ts b/src/modules/creators/creators.serializers.ts index e3cc3ba..0ef7091 100644 --- a/src/modules/creators/creators.serializers.ts +++ b/src/modules/creators/creators.serializers.ts @@ -121,10 +121,11 @@ export function normalizeCreatorListItems( * @param profiles - Array of full creator profiles (null/undefined treated as empty) * @returns Array of creator list items */ -export function serializeCreatorList( +export async function serializeCreatorList( profiles: CreatorProfile[] | null | undefined -): CreatorListItem[] { - return normalizeCreatorListItems(profiles).map(mapCreatorListItem); +): Promise { + const items = normalizeCreatorListItems(profiles); + return Promise.all(items.map(mapCreatorListItem)); } /** @@ -187,12 +188,12 @@ export type CreatorCursorListResponse = PublicCreatorListEnvelope< * This centralizes the wrapping of creators and metadata to ensure * a consistent public response shape (envelope). */ -export function serializeCreatorListResponse( +export async function serializeCreatorListResponse( profiles: CreatorProfile[], meta: OffsetPaginationMeta -): CreatorListResponse { +): Promise { return wrapPublicCreatorListResponse( - serializeCreatorList(profiles), + await serializeCreatorList(profiles), serializeCreatorListOffsetMeta(meta) ); } @@ -203,12 +204,12 @@ export function serializeCreatorListResponse( * This keeps cursor metadata and creator summary shaping out of route handlers * while reusing the existing public list envelope shape. */ -export function serializeCursorAwareCreatorListResponse( +export async function serializeCursorAwareCreatorListResponse( profiles: CreatorProfile[], meta: CursorPaginationMeta -): CreatorCursorListResponse { +): Promise { return wrapPublicCreatorListResponse( - serializeCreatorList(profiles), + await serializeCreatorList(profiles), serializeCreatorListCursorMeta(meta) ); } diff --git a/src/modules/creators/creators.utils.ts b/src/modules/creators/creators.utils.ts index fe76060..ed207ac 100644 --- a/src/modules/creators/creators.utils.ts +++ b/src/modules/creators/creators.utils.ts @@ -127,10 +127,10 @@ export async function fetchCreatorList( * const emptyResponse = createEmptyCreatorListResponse(validatedQuery); * // Returns: { items: [], meta: { limit, offset, total: 0, hasMore: false } } */ -export function createEmptyCreatorListResponse( +export async function createEmptyCreatorListResponse( query: CreatorListQueryType -): CreatorListResponse { - return serializeCreatorListResponse( +): Promise { + return await serializeCreatorListResponse( [], buildOffsetPaginationMeta({ limit: query.limit, diff --git a/src/modules/indexer/price-snapshot.service.ts b/src/modules/indexer/price-snapshot.service.ts index eb66b6c..95fc640 100644 --- a/src/modules/indexer/price-snapshot.service.ts +++ b/src/modules/indexer/price-snapshot.service.ts @@ -44,6 +44,13 @@ export async function upsertPriceSnapshot( lastTradeAt: tradeAt, }, }); + await prisma.creatorPriceHistory.create({ + data: { + creatorId, + price, + recordedAt: tradeAt, + }, + }); logger.debug( { creator_id: creatorId, @@ -86,6 +93,13 @@ export async function upsertPriceSnapshot( lastTradeAt: tradeAt, }, }); + await prisma.creatorPriceHistory.create({ + data: { + creatorId, + price, + recordedAt: tradeAt, + }, + }); logger.debug( { creator_id: creatorId, diff --git a/src/utils/price-change.utils.test.ts b/src/utils/price-change.utils.test.ts new file mode 100644 index 0000000..d89700a --- /dev/null +++ b/src/utils/price-change.utils.test.ts @@ -0,0 +1,70 @@ +// src/utils/price-change.utils.test.ts + +import { computePriceChange } from './price-change.utils'; +import type { PrismaClient } from '@prisma/client'; + +describe('computePriceChange()', () => { + const creatorId = 'creator-test-1'; + const windowMs = 3_600_000; // 1 hour + + let findManyMock: jest.Mock; + let db: jest.Mocked>; + + beforeEach(() => { + findManyMock = jest.fn(); + db = { + creatorPriceHistory: { + findMany: findManyMock, + } as any, + }; + }); + + it('returns a positive percentage when the price increased', async () => { + findManyMock.mockResolvedValue([ + { price: BigInt(100) }, + { price: BigInt(150) }, + ]); + + const result = await computePriceChange(creatorId, windowMs, db as any); + + expect(result).toBe(50); + }); + + it('returns a negative percentage when the price decreased', async () => { + findManyMock.mockResolvedValue([ + { price: BigInt(200) }, + { price: BigInt(120) }, + ]); + + const result = await computePriceChange(creatorId, windowMs, db as any); + + expect(result).toBe(-40); + }); + + it('returns null when fewer than two snapshots exist', async () => { + findManyMock.mockResolvedValue([{ price: BigInt(100) }]); + + const result = await computePriceChange(creatorId, windowMs, db as any); + + expect(result).toBeNull(); + }); + + it('returns null when no snapshots exist', async () => { + findManyMock.mockResolvedValue([]); + + const result = await computePriceChange(creatorId, windowMs, db as any); + + expect(result).toBeNull(); + }); + + it('rounds the percentage to two decimal places', async () => { + findManyMock.mockResolvedValue([ + { price: BigInt(300) }, + { price: BigInt(100) }, + ]); + + const result = await computePriceChange(creatorId, windowMs, db as any); + + expect(result).toBeCloseTo(-66.67, 2); + }); +}); diff --git a/src/utils/price-change.utils.ts b/src/utils/price-change.utils.ts new file mode 100644 index 0000000..f608e5d --- /dev/null +++ b/src/utils/price-change.utils.ts @@ -0,0 +1,64 @@ +// src/utils/price-change.utils.ts + +interface PriceHistoryClient { + creatorPriceHistory?: { + findMany(args: { + where: { creatorId: string; recordedAt: { gte: Date } }; + orderBy: { recordedAt: 'asc' }; + select: { price: true }; + }): Promise>; + }; +} + +const OLDEST_RECORD_INDEX = 0; + +/** + * Compute the percentage change in a creator's key price over a given time window. + * + * Queries the price history table for records within `windowMs` and compares + * the oldest and latest prices. Returns `null` when fewer than two records exist + * in the window or when the oldest price is zero. + * + * @param creatorId - The creator to compute the change for. + * @param windowMs - Time window in milliseconds (e.g. 3600000 for 1h). + * @param db - Prisma client instance. + * + * @returns Signed percentage rounded to two decimal places, or `null`. + */ +export async function computePriceChange( + creatorId: string, + windowMs: number, + db: PriceHistoryClient +): Promise { + if (!db.creatorPriceHistory) { + return null; + } + + const cutoff = new Date(Date.now() - windowMs); + + const snapshots = await db.creatorPriceHistory.findMany({ + where: { + creatorId, + recordedAt: { gte: cutoff }, + }, + orderBy: { recordedAt: 'asc' }, + select: { price: true }, + }); + + if (snapshots.length < 2) { + return null; + } + + const oldestPrice = snapshots[OLDEST_RECORD_INDEX].price; + const latestPrice = snapshots[snapshots.length - 1].price; + + if (oldestPrice === BigInt(0)) { + return null; + } + + const change = Number(latestPrice - oldestPrice); + const base = Number(oldestPrice); + const percentage = (change / base) * 100; + + return parseFloat(percentage.toFixed(2)); +}