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
2 changes: 1 addition & 1 deletion prisma/schema/follow.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ model WalletCreatorFollow {
@@index([creatorId])
@@index([walletAddress])
@@map("wallet_creator_follows")
}
}
18 changes: 9 additions & 9 deletions prisma/schema/ownership.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@ model KeyOwnership {
balance Decimal @default(0)
costBasis Decimal? @default(0)

/// Timestamp of the owner's most recent buy of this key, if any.
lastBuyAt DateTime?
/// 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?

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

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

@@unique([ownerAddress, creatorId])
@@index([ownerAddress])
@@index([creatorId])
}
@@unique([ownerAddress, creatorId])
@@index([ownerAddress])
@@index([creatorId])
}
22 changes: 13 additions & 9 deletions src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,6 @@ export const envSchema = z

// Stellar auth — optional server keypair secret used for SEP-10 challenge
// signing. When absent the server falls back to an ephemeral random keypair.
STELLAR_AUTH_SECRET: optionalNonEmptyString,

// Stellar network
STELLAR_NETWORK: z
Expand All @@ -177,9 +176,11 @@ export const envSchema = z
.url(
'STELLAR_SOROBAN_RPC_URL must be a valid URL (e.g. https://soroban-testnet.stellar.org)'
)
.default('https://soroban-testnet.stellar.org'),
.default('https://soroban-testnet.stellar.org'),

// Ownership snapshot cleanup job
STELLAR_AUTH_SECRET: z.string().min(32).default('accesslayer_default_development_stellar_auth_secret_32b'),

// Ownership snapshot cleanup job
OWNERSHIP_SNAPSHOT_TABLE_NAME: z
.string()
.min(1)
Expand Down Expand Up @@ -265,30 +266,33 @@ export const envSchema = z
.default(5000),
SSE_REPLAY_MAX_EVENTS: z.coerce.number().int().positive().default(100),


// SSE subscription management (src/modules/subscriptions) — a wallet's
// subscription set, persisted in Redis, distinct from the per-connection
// heartbeat/queue/replay tuning above.
SSE_MAX_CONNECTIONS_PER_WALLET: z.coerce
.number()
.int()
.positive()
.default(10),
SSE_SUBSCRIPTION_TTL_MS: z.coerce

.default(5),
SSE_MAX_SUBSCRIPTIONS_PER_WALLET: z.coerce
.number()
.int()
.positive()
.default(300000),
SSE_MAX_SUBSCRIPTIONS_PER_WALLET: z.coerce

.default(10),
SSE_SUBSCRIPTION_TTL_MS: z.coerce
.number()
.int()
.positive()
.default(10),
.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
2 changes: 1 addition & 1 deletion src/constants/error.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@
FORBIDDEN: 'FORBIDDEN',
CONFLICT: 'CONFLICT',
BAD_REQUEST: 'BAD_REQUEST',
UNPROCESSABLE_ENTITY: 'UNPROCESSABLE_ENTITY',
INTERNAL_ERROR: 'INTERNAL_ERROR',
RATE_LIMIT: 'RATE_LIMIT',
PRISMA_ERROR: 'DATABASE_ERROR',
JWT_ERROR: 'TOKEN_ERROR',
INSUFFICIENT_BALANCE: 'insufficient_balance',
NOT_A_CREATOR: 'not_a_creator',
UNPROCESSABLE_ENTITY: 'UNPROCESSABLE_ENTITY',

Check failure on line 19 in src/constants/error.constants.ts

View workflow job for this annotation

GitHub Actions / verify

An object literal cannot have multiple properties with the same name.
} as const;

export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode];
15 changes: 11 additions & 4 deletions src/modules/admin/key-sync.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ describe('Key Sync Integration Tests', () => {
const user = await prisma.user.create({
data: {
email: `test-${Date.now()}@example.com`,
passwordHash: 'test-hash',

passwordHash: 'hash',

firstName: 'Test',
lastName: 'User',
stellarWallet: { create: { address: 'GBTEST0001' } },
Expand All @@ -48,8 +50,11 @@ describe('Key Sync Integration Tests', () => {
// Create price snapshot
await prisma.creatorPriceSnapshot.create({
data: {
creatorId: creator.id,
currentPrice: 100,

creatorId: testCreatorId,
currentPrice: 100n,


lastTradeAt: new Date(),
},
});
Expand All @@ -59,7 +64,9 @@ describe('Key Sync Integration Tests', () => {
await prisma.keyOwnership.create({
data: {
ownerAddress: `GHOLDER${String(i).padStart(52, '0')}`,
creatorId: creator.id,

creatorId: testCreatorId,

balance: 100,
},
});
Expand Down
9 changes: 4 additions & 5 deletions src/modules/creator/creator.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,15 @@
'capBps must be between 100 and 2500'
);
return;
} const keyId = Array.isArray(req.params.keyId)

Check failure on line 77 in src/modules/creator/creator.routes.ts

View workflow job for this annotation

GitHub Actions / verify

Cannot redeclare block-scoped variable 'keyId'.
? req.params.keyId[0]
: req.params.keyId;

const keyId = Array.isArray(req.params.keyId)
? req.params.keyId[0]
: req.params.keyId;
const keyId = Array.isArray(req.params.keyId) ? req.params.keyId[0] : req.params.keyId;

Check failure on line 81 in src/modules/creator/creator.routes.ts

View workflow job for this annotation

GitHub Actions / verify

Cannot redeclare block-scoped variable 'keyId'.

try {
const creatorProfile = await prisma.creatorProfile.findFirst({
where: { OR: [{ id: keyId }, { handle: keyId }] },
const creatorProfile = await prisma.creatorProfile.findFirst({
where: { OR: [{ id: keyId }, { handle: keyId }] },
});
if (!creatorProfile) {
sendError(res, 404, ErrorCode.NOT_FOUND, 'Key not found');
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 @@ -80,10 +80,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 @@ -92,7 +95,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: 3 additions & 1 deletion src/modules/dividends/dividend-endpoint.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,9 @@ describe('Dividend Endpoints Integration Tests', () => {
const user = await prisma.user.create({
data: {
email: `test2-${Date.now()}@example.com`,
passwordHash: 'test-hash',

passwordHash: 'hash123',

firstName: 'Test',
lastName: 'User',
stellarWallet: { create: { address: 'GBTEST0002' } },
Expand Down
Loading
Loading