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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"preinstall": "node -e \"const ua = process.env.npm_config_user_agent || ''; if (!ua.includes('pnpm')) { console.error('Use pnpm for this repository. Run: pnpm install'); process.exit(1); }\"",
"dev": "nodemon",
"start": "node dist/server.js",
"prebuild": "prisma generate",
"build": "tsc",
"test": "jest",
"format": "prettier --write .",
Expand Down
11 changes: 11 additions & 0 deletions prisma/schema/follow.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,14 @@ model Follow {
@@index([creatorId])
@@index([followerAddress])
}

model WalletCreatorFollow {
id String @id @default(cuid())
walletAddress String
creatorId String
createdAt DateTime @default(now())

@@unique([walletAddress, creatorId])
@@index([walletAddress])
@@index([creatorId])
}
5 changes: 4 additions & 1 deletion prisma/schema/ownership.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ model KeyOwnership {

/// When the current lockup window ends for this holding, if any.
lockupExpiresAt DateTime?


/// ISO timestamp of the holder's most recent buy, null when never bought.
lastBuyAt DateTime?

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand Down
25 changes: 25 additions & 0 deletions src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export const envSchema = z
.int()
.positive()
.default(900),
JWT_EXPIRES_IN: z.string().default('1h'),

// Redis cache
REDIS_URL: z.string().default('redis://localhost:6379'),
Expand Down Expand Up @@ -172,6 +173,10 @@ export const envSchema = z
'STELLAR_SOROBAN_RPC_URL must be a valid URL (e.g. https://soroban-testnet.stellar.org)'
)
.default('https://soroban-testnet.stellar.org'),
STELLAR_AUTH_SECRET: z
.string()
.min(32, 'STELLAR_AUTH_SECRET should be at least 32 characters')
.default('accesslayer_default_development_stellar_auth_secret_32b'),

// Ownership snapshot cleanup job
OWNERSHIP_SNAPSHOT_TABLE_NAME: z
Expand Down Expand Up @@ -240,6 +245,26 @@ export const envSchema = z
.positive()
.default(5000),
SSE_REPLAY_MAX_EVENTS: z.coerce.number().int().positive().default(100),
SSE_MAX_CONNECTIONS_PER_WALLET: z.coerce
.number()
.int()
.positive()
.default(5),
SSE_MAX_SUBSCRIPTIONS_PER_WALLET: z.coerce
.number()
.int()
.positive()
.default(10),
SSE_SUBSCRIPTION_TTL_MS: z.coerce
.number()
.int()
.positive()
.default(300000),
SSE_THROTTLE_DURATION_MS: z.coerce
.number()
.int()
.positive()
.default(1000),
})
.superRefine((data, ctx) => {
if (data.MODE === 'production' && data.STELLAR_NETWORK === 'testnet') {
Expand Down
1 change: 0 additions & 1 deletion src/modules/admin/audit-log-endpoint.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createServer } from '../../utils/server.utils';
import { prisma } from '../../utils/prisma.utils';
Expand Down
1 change: 0 additions & 1 deletion src/modules/admin/audit-log.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { prisma } from '../../utils/prisma.utils';
import { createAuditEntry, getAuditLogs } from './audit-log.service';

Expand Down
12 changes: 7 additions & 5 deletions src/modules/admin/key-sync.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createServer } from '../../utils/server.utils';
import { prisma } from '../../utils/prisma.utils';
Expand Down Expand Up @@ -27,6 +26,9 @@ describe('Key Sync Integration Tests', () => {
const user = await prisma.user.create({
data: {
email: `test-${Date.now()}@example.com`,
passwordHash: 'hash',
firstName: 'Test',
lastName: 'User',
stellarWallet: { create: { address: 'GBTEST0001' } },
},
});
Expand All @@ -46,9 +48,9 @@ describe('Key Sync Integration Tests', () => {
// Create price snapshot
await prisma.creatorPriceSnapshot.create({
data: {
creatorId,
price: 100,
priceUpdatedAt: new Date(),
creatorId: testCreatorId,
currentPrice: 100n,
lastTradeAt: new Date(),
},
});

Expand All @@ -57,7 +59,7 @@ describe('Key Sync Integration Tests', () => {
await prisma.keyOwnership.create({
data: {
ownerAddress: `GHOLDER${String(i).padStart(52, '0')}`,
creatorId,
creatorId: testCreatorId,
balance: 100,
},
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Integration test: creator list no-results state for an unmatched search term
//
// Verifies that when a search query returns zero creators the list response
// exposes a distinct `noResults` state with a message that references the
// search term, and that this state is separate from the unfiltered empty
// state (which uses `state: 'empty'` and no message). Uses Jest mocks so no
// database is required.

import { httpListCreators } from './creators.controllers';
import * as creatorsUtils from './creators.utils';

// ── Lightweight request/response mocks ────────────────────────────────────────

const SEARCH_TERM = 'zzznomatch';

function makeReq(query: Record<string, string> = {}): any {
return { query };
}

function makeRes(): any {
const res: any = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.setHeader = jest.fn().mockReturnValue(res);
res.set = jest.fn().mockReturnValue(res);
return res;
}

function makeNext(): jest.Mock {
return jest.fn();
}

function getBody(res: any) {
return res.json.mock.calls[0][0];
}

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('GET /api/v1/creators — no-results state for unmatched search', () => {
beforeEach(() => {
// Mock the creator search to return zero results for every query.
jest
.spyOn(creatorsUtils, 'fetchCreatorList')
.mockResolvedValue([[], 0]);
});

afterEach(() => {
jest.restoreAllMocks();
});

it('returns a no-results message that references the search term', async () => {
const req = makeReq({ search: SEARCH_TERM });
const res = makeRes();
await httpListCreators(req, res, makeNext());

expect(res.status).toHaveBeenCalledWith(200);
const body = getBody(res);
expect(body.data.state).toBe('noResults');
expect(body.data.message).toEqual(
expect.stringContaining(SEARCH_TERM)
);
});

it('keeps the no-results state distinct from the unfiltered empty state', async () => {
const searchRes = makeRes();
await httpListCreators(makeReq({ search: SEARCH_TERM }), searchRes, makeNext());
const noResultsBody = getBody(searchRes);

const emptyRes = makeRes();
await httpListCreators(makeReq(), emptyRes, makeNext());
const emptyBody = getBody(emptyRes);

// Search with zero matches → noResults + message
expect(noResultsBody.data.state).toBe('noResults');
expect(noResultsBody.data.message).toBeDefined();

// Unfiltered empty list → empty, no message
expect(emptyBody.data.state).toBe('empty');
expect(emptyBody.data.message).toBeUndefined();

// The two states must differ
expect(noResultsBody.data.state).not.toBe(emptyBody.data.state);
});

it('removes the no-results state when the search input is cleared', async () => {
const searchRes = makeRes();
await httpListCreators(makeReq({ search: SEARCH_TERM }), searchRes, makeNext());
expect(getBody(searchRes).data.state).toBe('noResults');

// Clearing the search returns the unfiltered empty state
const clearedRes = makeRes();
await httpListCreators(makeReq(), clearedRes, makeNext());
const clearedBody = getBody(clearedRes);

expect(clearedBody.data.state).toBe('empty');
expect(clearedBody.data.message).toBeUndefined();
});

it('still reports zero total creators for the no-results search', async () => {
const req = makeReq({ search: SEARCH_TERM });
const res = makeRes();
await httpListCreators(req, res, makeNext());

const body = getBody(res);
expect(body.data.items).toEqual([]);
expect(body.data.meta.total).toBe(0);
expect(body.data.meta.hasMore).toBe(false);
});
});
6 changes: 4 additions & 2 deletions src/modules/creators/creators.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,13 @@ export const httpListCreators: AsyncController = async (req, res, next) => {
limit: validatedQuery.limit,
offset: validatedQuery.offset,
total,
}),
{
search: validatedQuery.search,
...(validatedQuery.search !== undefined && total === 0
? { searchTerm: validatedQuery.search }
: {}),
})
}
);

attachTimestampHeader(res);
Expand All @@ -88,7 +91,6 @@ export const httpListCreators: AsyncController = async (req, res, next) => {
next(error);
}
};

/**
* Categorize a parse error based on the validation details.
*
Expand Down
59 changes: 52 additions & 7 deletions src/modules/creators/creators.serializers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,31 @@ export function serializeCreatorListOffsetMeta(
};
}

/**
* Distinguishes an empty list from a "no matches" search result so that
* clients can render a tailored no-results message instead of the generic
* empty state.
*
* - `results` — at least one creator was returned.
* - `empty` — no creators exist and no search/filter narrowed the list.
* - `noResults`— a search term was supplied but matched zero creators.
*/
export type CreatorListState = 'results' | 'empty' | 'noResults';

/**
* Paginated creator list response body (offset pagination metadata).
*
* Adds `state` and an optional `message` so clients can differentiate the
* unfiltered empty list from a zero-result search and surface a message that
* references the search term.
*/
export type CreatorListResponse = PublicCreatorListEnvelope<
CreatorListItem,
OffsetPaginationMeta
>;
> & {
state: CreatorListState;
message?: string;
};

/**
* Cursor-aware creator list response body.
Expand All @@ -186,16 +204,43 @@ export type CreatorCursorListResponse = PublicCreatorListEnvelope<
* Serializes a standard offset-paginated creator list response.
*
* This centralizes the wrapping of creators and metadata to ensure
* a consistent public response shape (envelope).
* a consistent public response shape (envelope). When the result set is
* empty, `state` distinguishes an unfiltered empty list (`empty`) from a
* zero-result search (`noResults`); the latter includes a `message` that
* references the supplied search term so clients can render a tailored
* no-results state.
*
* @param profiles - Creator profiles (null/undefined treated as empty)
* @param meta - Offset pagination metadata
* @param options - Serialization context (e.g. the active search term)
*/
export async function serializeCreatorListResponse(
profiles: CreatorProfile[],
meta: OffsetPaginationMeta
meta: OffsetPaginationMeta,
options: { search?: string } = {}
): Promise<CreatorListResponse> {
return wrapPublicCreatorListResponse(
await serializeCreatorList(profiles),
serializeCreatorListOffsetMeta(meta)
);
const items = await serializeCreatorList(profiles);

let state: CreatorListState;
let message: string | undefined;

if (meta.total > 0) {
state = 'results';
} else if (options.search) {
state = 'noResults';
message = `No creators match "${options.search}". Try a different search term.`;
} else {
state = 'empty';
}

return {
...wrapPublicCreatorListResponse(
items,
serializeCreatorListOffsetMeta(meta)
),
state,
...(message ? { message } : {}),
};
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/modules/dividends/dividend-endpoint.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import request from 'supertest';
import { createServer } from '../../utils/server.utils';
import { prisma } from '../../utils/prisma.utils';
import { processDividendEvents } from '../indexer/dividend-indexer.service';
import { IndexerChainEvent } from '../../utils/indexer-event-processor.utils';
Expand Down Expand Up @@ -234,6 +235,9 @@ describe('Dividend Endpoints Integration Tests', () => {
const user = await prisma.user.create({
data: {
email: `test2-${Date.now()}@example.com`,
passwordHash: 'hash123',
firstName: 'Test',
lastName: 'User',
stellarWallet: { create: { address: 'GBTEST0002' } },
},
});
Expand Down
2 changes: 1 addition & 1 deletion src/modules/investor/dividend.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export async function getInvestorDividends(
}
const items = await prisma.dividendDistribution.findMany({
where,
orderBy: { distributedAt: 'desc' },
orderBy: { createdAt: 'desc' },
take: limit + 1,
});
const hasMore = items.length > limit;
Expand Down
2 changes: 2 additions & 0 deletions src/modules/keys/keys.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
PRICE_HISTORY_INTERVALS,
} from './key-price-history.service';
import { getKeyFees, KeyNotFoundError } from './key-fees.service';
import { getKeyProposals, KeyNotFoundError as ProposalKeyNotFoundError } from './key-proposals.service';
import { getKeySupply, KeyNotFoundError as SupplyKeyNotFoundError } from './key-supply.service';
import { KeySearchQueryTooShortError, searchKeys } from './key-search.service';
import { KEY_SEARCH_MIN_QUERY_LENGTH } from '../../constants/notifications.constants';
import dividendRouter from '../dividends/dividend.routes';
Expand Down
Loading
Loading