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
9,317 changes: 4,194 additions & 5,123 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions prisma/schema/creator.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ model CreatorProfile {

user User @relation(fields: [userId], references: [id], onDelete: Cascade)
priceSnapshot CreatorPriceSnapshot?
priceHistory CreatorPriceHistory[]
posts CreatorPost[]
}

Expand Down
15 changes: 15 additions & 0 deletions prisma/schema/price-history.prisma
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
4 changes: 4 additions & 0 deletions src/modules/creator/creator-profile.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion src/modules/creator/creator-profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions src/modules/creators/creator-list-item.mapper.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
22 changes: 17 additions & 5 deletions src/modules/creators/creator-list-item.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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<CreatorListItem> => {
warnIfUnexpectedNullCreatorField(creator, 'displayName');

logIfFieldTypeMismatch(creator, 'id');
Expand All @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion src/modules/creators/creators.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 10 additions & 9 deletions src/modules/creators/creators.serializers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CreatorListItem[]> {
const items = normalizeCreatorListItems(profiles);
return Promise.all(items.map(mapCreatorListItem));
}

/**
Expand Down Expand Up @@ -186,12 +187,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<CreatorListResponse> {
return wrapPublicCreatorListResponse(
serializeCreatorList(profiles),
await serializeCreatorList(profiles),
serializeCreatorListOffsetMeta(meta)
);
}
Expand All @@ -202,12 +203,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<CreatorCursorListResponse> {
return wrapPublicCreatorListResponse(
serializeCreatorList(profiles),
await serializeCreatorList(profiles),
serializeCreatorListCursorMeta(meta)
);
}
6 changes: 3 additions & 3 deletions src/modules/creators/creators.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,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<CreatorListResponse> {
return await serializeCreatorListResponse(
[],
buildOffsetPaginationMeta({
limit: query.limit,
Expand Down
14 changes: 14 additions & 0 deletions src/modules/indexer/price-snapshot.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ export async function upsertPriceSnapshot(
lastTradeAt: tradeAt,
},
});
await prisma.creatorPriceHistory.create({
data: {
creatorId,
price,
recordedAt: tradeAt,
},
});
logger.debug(
{
creator_id: creatorId,
Expand Down Expand Up @@ -86,6 +93,13 @@ export async function upsertPriceSnapshot(
lastTradeAt: tradeAt,
},
});
await prisma.creatorPriceHistory.create({
data: {
creatorId,
price,
recordedAt: tradeAt,
},
});
logger.debug(
{
creator_id: creatorId,
Expand Down
70 changes: 70 additions & 0 deletions src/utils/price-change.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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<Pick<PrismaClient, 'creatorPriceHistory'>>;

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);
});
});
64 changes: 64 additions & 0 deletions src/utils/price-change.utils.ts
Original file line number Diff line number Diff line change
@@ -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<Array<{ price: bigint }>>;
};
}

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<number | null> {
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));
}
Loading