Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,5 @@ ENABLE_SCHEMA_VERSION_HEADER=true
ENABLE_RESPONSE_TIMING=true
ENABLE_REQUEST_LOGGING=true
BACKGROUND_JOB_LOCK_TTL_MS=300000
# Indexer cursor stale-age warning threshold in milliseconds (default: 300000 = 5 minutes)
INDEXER_CURSOR_STALE_AGE_WARNING_MS=300000
64 changes: 64 additions & 0 deletions docs/boolean-query-parser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Boolean Query Parser

The API uses a consistent boolean query parser across all endpoints that accept boolean-like query parameters (e.g. `verified`, `isActive`).

## Accepted values

| Input | Parsed as |
|----------|-----------|
| `true` | `true` |
| `1` | `true` |
| `yes` | `true` |
| `on` | `true` |
| `false` | `false` |
| `0` | `false` |
| `no` | `false` |
| `off` | `false` |
| *(absent)* | `null` — caller applies its own default |

Values are **case-insensitive** (`True`, `TRUE`, `On` all parse correctly).
Leading and trailing whitespace is stripped before comparison.

## Examples

```http
GET /api/v1/creators?verified=true → verified: true
GET /api/v1/creators?verified=1 → verified: true
GET /api/v1/creators?verified=yes → verified: true
GET /api/v1/creators?verified=on → verified: true
GET /api/v1/creators?verified=false → verified: false
GET /api/v1/creators?verified=0 → verified: false
GET /api/v1/creators?verified=no → verified: false
GET /api/v1/creators?verified=off → verified: false
GET /api/v1/creators → verified: absent (endpoint default applies)
```

## Error response

Any value not in the accepted list produces a `400 Bad Request`:

```json
{
"success": false,
"code": "VALIDATION_ERROR",
"message": "Invalid boolean value for query parameter \"verified\": received \"maybe\". Accepted values: \"true\", \"false\", \"1\", \"0\", \"yes\", \"no\", \"on\", \"off\"."
}
```

## Query validation integration

Boolean query parameters are validated at the schema level before reaching controller logic. If a request carries an invalid boolean value the endpoint returns a `400` before any database access.

See the query validation schemas in `src/modules/creators/creators.schemas.ts` for how parameters are parsed at the route level, and `src/utils/parseBoolean.utils.ts` for the shared parser used across endpoints.

## Implementation reference

```typescript
import { parseBoolean, parseBooleanWithDefault } from '../utils/parseBoolean.utils';

// Returns true | false | null (null when param absent)
const verified = parseBoolean('verified', req.query.verified);

// Returns true | false; falls back to false when param absent
const verified = parseBooleanWithDefault('verified', req.query.verified, false);
```
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export const envSchema = z.object({
INDEXER_JITTER_FACTOR: z.coerce.number().min(0).max(1).default(0.1),
BACKGROUND_JOB_LOCK_TTL_MS: z.coerce.number().int().positive().default(300000),
CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().positive().default(500),
INDEXER_CURSOR_STALE_AGE_WARNING_MS: z.coerce.number().int().positive().default(300000),
});

export const envConfig = envSchema.parse(process.env);
Expand Down
17 changes: 17 additions & 0 deletions src/middlewares/error.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ErrorRequestHandler } from 'express';
import chalk from 'chalk';
import { z } from 'zod';
import { ErrorCode, ErrorCodeType } from '../constants/error.constants';
import { logger } from '../utils/logger.utils';

export class ApiError extends Error {
statusCode: number;
Expand Down Expand Up @@ -124,6 +125,22 @@ export const errorHandler: ErrorRequestHandler = (
return;
}

// Handle oversized request payload (413)
if (err.type === 'entity.too.large' || err.status === 413 || err.statusCode === 413) {
logger.warn({
msg: 'Request payload too large',
route: `${req.method} ${req.originalUrl}`,
contentLength: req.headers['content-length'],
limitBytes: err.limit,
});
res.status(413).json({
success: false,
code: ErrorCode.BAD_REQUEST,
message: 'Request payload too large',
});
return;
}

// Handle syntax errors (malformed JSON)
if (err instanceof SyntaxError && 'body' in err) {
res.status(400).json({
Expand Down
54 changes: 54 additions & 0 deletions src/modules/creators/creator-feed-filter-combinator.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { buildCreatorFeedWhere } from './creator-feed-filter-combinator.utils';

describe('buildCreatorFeedWhere()', () => {
it('returns an empty object when no filters are supplied', () => {
expect(buildCreatorFeedWhere({})).toEqual({});
});

it('sets isVerified when verified is true', () => {
expect(buildCreatorFeedWhere({ verified: true })).toEqual({ isVerified: true });
});

it('sets isVerified when verified is false', () => {
expect(buildCreatorFeedWhere({ verified: false })).toEqual({ isVerified: false });
});

it('omits isVerified when verified is undefined', () => {
const where = buildCreatorFeedWhere({ search: 'jazz' });
expect('isVerified' in where).toBe(false);
});

it('sets OR search clause for handle and displayName', () => {
const where = buildCreatorFeedWhere({ search: 'jazz' });
expect(where.OR).toEqual([
{ handle: { contains: 'jazz', mode: 'insensitive' } },
{ displayName: { contains: 'jazz', mode: 'insensitive' } },
]);
});

it('normalizes whitespace in the search term', () => {
const where = buildCreatorFeedWhere({ search: ' jazz musician ' });
expect(where.OR?.[0].handle?.contains).toBe('jazz musician');
});

it('omits OR clause when search is whitespace-only', () => {
const where = buildCreatorFeedWhere({ search: ' ' });
expect('OR' in where).toBe(false);
});

it('omits OR clause when search is undefined', () => {
const where = buildCreatorFeedWhere({ verified: true });
expect('OR' in where).toBe(false);
});

it('combines verified and search filters', () => {
const where = buildCreatorFeedWhere({ verified: false, search: 'alice' });
expect(where).toEqual({
isVerified: false,
OR: [
{ handle: { contains: 'alice', mode: 'insensitive' } },
{ displayName: { contains: 'alice', mode: 'insensitive' } },
],
});
});
});
48 changes: 48 additions & 0 deletions src/modules/creators/creator-feed-filter-combinator.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// src/modules/creators/creator-feed-filter-combinator.utils.ts
// Centralises creator-feed WHERE clause composition so feed handlers don't duplicate combinator logic.

import { CreatorFilterInput } from './creators.filter';
import { normalizeCreatorListSearchTerm } from './creators.search-term.utils';

export type CreatorFeedWhere = {
isVerified?: boolean;
OR?: Array<{
handle?: { contains: string; mode: 'insensitive' };
displayName?: { contains: string; mode: 'insensitive' };
}>;
};

/**
* Composes a Prisma `where` clause for creator feed queries from a parsed filter input.
*
* Keeps filter semantics identical to the creator list endpoint while giving
* feed handlers a single call-site instead of inline combinator branches.
*
* @param filters - Parsed creator filter input (verified, search)
* @returns A Prisma-compatible where object ready for `prisma.creatorProfile.findMany`
*
* @example
* const where = buildCreatorFeedWhere({ verified: true, search: 'jazz' });
* // => { isVerified: true, OR: [{ handle: ... }, { displayName: ... }] }
*
* @example
* const where = buildCreatorFeedWhere({});
* // => {}
*/
export function buildCreatorFeedWhere(filters: CreatorFilterInput): CreatorFeedWhere {
const where: CreatorFeedWhere = {};

if (filters.verified !== undefined) {
where.isVerified = filters.verified;
}

const normalizedSearch = normalizeCreatorListSearchTerm(filters.search);
if (normalizedSearch) {
where.OR = [
{ handle: { contains: normalizedSearch, mode: 'insensitive' } },
{ displayName: { contains: normalizedSearch, mode: 'insensitive' } },
];
}

return where;
}
29 changes: 3 additions & 26 deletions src/modules/creators/creators.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,9 @@ import { CreatorListQueryType } from './creators.schemas';
import { mapCreatorListSort } from './creators.sort';
import { serializeCreatorListResponse, CreatorListResponse } from './creators.serializers';
import { buildOffsetPaginationMeta } from '../../utils/pagination.utils';
import { normalizeCreatorListSearchTerm } from './creators.search-term.utils';
import { logger } from '../../utils/logger.utils';
import { envConfig } from '../../config';

type CreatorListWhere = {
isVerified?: boolean;
OR?: Array<{
handle?: { contains: string; mode: 'insensitive' };
displayName?: { contains: string; mode: 'insensitive' };
}>;
};
import { buildCreatorFeedWhere } from './creator-feed-filter-combinator.utils';

/**
* Fetch paginated list of creators from the database.
Expand All @@ -27,22 +19,7 @@ export async function fetchCreatorList(
): Promise<[CreatorProfile[], number]> {
const { limit, offset, sort, order, verified, search } = query;

// Build where clause for filters
const where: CreatorListWhere = {};

if (verified !== undefined) {
where.isVerified = verified;
}

const normalizedSearch = normalizeCreatorListSearchTerm(search);

if (normalizedSearch) {
where.OR = [
{ handle: { contains: normalizedSearch, mode: 'insensitive' } },
{ displayName: { contains: normalizedSearch, mode: 'insensitive' } },
];
}

const where = buildCreatorFeedWhere({ verified, search });
const orderBy = mapCreatorListSort(sort, order);

// Fetch creators and total count in parallel
Expand All @@ -65,7 +42,7 @@ export async function fetchCreatorList(
thresholdMs: envConfig.CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS,
sort,
order,
hasSearch: !!normalizedSearch,
hasSearch: !!search,
hasVerifiedFilter: verified !== undefined,
limit,
offset,
Expand Down
30 changes: 30 additions & 0 deletions src/utils/indexer-cursor-staleness.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { logger } from './logger.utils';
import { envConfig } from '../config';

/**
* Emits a structured warning when the indexer cursor has not been updated within the
* configured stale-age threshold.
*
* Call this in the indexer health-check path or polling loop after reading the cursor
* from its backing store.
*
* Default threshold: `INDEXER_CURSOR_STALE_AGE_WARNING_MS` env variable (300 000 ms / 5 min).
* Override with the `thresholdMs` parameter for per-call control.
*
* @param lastUpdatedAt - Timestamp of the cursor's most recent update
* @param thresholdMs - Optional override; defaults to env config value
*/
export function warnIfIndexerCursorStale(
lastUpdatedAt: Date,
thresholdMs: number = envConfig.INDEXER_CURSOR_STALE_AGE_WARNING_MS
): void {
const ageMs = Date.now() - lastUpdatedAt.getTime();
if (ageMs > thresholdMs) {
logger.warn({
msg: 'Indexer cursor is stale',
lastUpdatedAt: lastUpdatedAt.toISOString(),
ageMs,
thresholdMs,
});
}
}
54 changes: 54 additions & 0 deletions src/utils/test/indexer-cursor-staleness.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { warnIfIndexerCursorStale } from '../indexer-cursor-staleness.utils';
import { logger } from '../logger.utils';

jest.mock('../logger.utils', () => ({
logger: { warn: jest.fn() },
}));

const warnMock = logger.warn as jest.Mock;

beforeEach(() => {
warnMock.mockClear();
});

describe('warnIfIndexerCursorStale()', () => {
it('emits a warning when cursor age exceeds the threshold', () => {
const sixMinutesAgo = new Date(Date.now() - 360_000);
warnIfIndexerCursorStale(sixMinutesAgo, 300_000);
expect(warnMock).toHaveBeenCalledTimes(1);
expect(warnMock).toHaveBeenCalledWith(
expect.objectContaining({
msg: 'Indexer cursor is stale',
thresholdMs: 300_000,
})
);
});

it('does not emit a warning when cursor age is within the threshold', () => {
const oneMinuteAgo = new Date(Date.now() - 60_000);
warnIfIndexerCursorStale(oneMinuteAgo, 300_000);
expect(warnMock).not.toHaveBeenCalled();
});

it('does not emit a warning when cursor age exactly equals the threshold', () => {
const exactly = new Date(Date.now() - 300_000);
warnIfIndexerCursorStale(exactly, 300_000);
expect(warnMock).not.toHaveBeenCalled();
});

it('includes lastUpdatedAt, ageMs and thresholdMs in the warning payload', () => {
const ts = new Date(Date.now() - 400_000);
warnIfIndexerCursorStale(ts, 300_000);
const call = warnMock.mock.calls[0][0];
expect(call.lastUpdatedAt).toBe(ts.toISOString());
expect(typeof call.ageMs).toBe('number');
expect(call.ageMs).toBeGreaterThan(300_000);
expect(call.thresholdMs).toBe(300_000);
});

it('respects a custom threshold override', () => {
const twoSecondsAgo = new Date(Date.now() - 2_000);
warnIfIndexerCursorStale(twoSecondsAgo, 1_000);
expect(warnMock).toHaveBeenCalledTimes(1);
});
});
Loading