diff --git a/.env.example b/.env.example index 6c38aa1..cf11515 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docs/boolean-query-parser.md b/docs/boolean-query-parser.md new file mode 100644 index 0000000..847c549 --- /dev/null +++ b/docs/boolean-query-parser.md @@ -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); +``` diff --git a/src/config.ts b/src/config.ts index e5343c8..c1755cc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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); diff --git a/src/middlewares/error.middleware.ts b/src/middlewares/error.middleware.ts index afc4a1d..326158f 100644 --- a/src/middlewares/error.middleware.ts +++ b/src/middlewares/error.middleware.ts @@ -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; @@ -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({ diff --git a/src/modules/creators/creator-feed-filter-combinator.utils.test.ts b/src/modules/creators/creator-feed-filter-combinator.utils.test.ts new file mode 100644 index 0000000..a6a8d52 --- /dev/null +++ b/src/modules/creators/creator-feed-filter-combinator.utils.test.ts @@ -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' } }, + ], + }); + }); +}); diff --git a/src/modules/creators/creator-feed-filter-combinator.utils.ts b/src/modules/creators/creator-feed-filter-combinator.utils.ts new file mode 100644 index 0000000..3aaeb26 --- /dev/null +++ b/src/modules/creators/creator-feed-filter-combinator.utils.ts @@ -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; +} diff --git a/src/modules/creators/creators.utils.ts b/src/modules/creators/creators.utils.ts index 34fd051..31a2b42 100644 --- a/src/modules/creators/creators.utils.ts +++ b/src/modules/creators/creators.utils.ts @@ -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. @@ -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 @@ -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, diff --git a/src/utils/indexer-cursor-staleness.utils.ts b/src/utils/indexer-cursor-staleness.utils.ts new file mode 100644 index 0000000..8286a84 --- /dev/null +++ b/src/utils/indexer-cursor-staleness.utils.ts @@ -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, + }); + } +} diff --git a/src/utils/test/indexer-cursor-staleness.utils.test.ts b/src/utils/test/indexer-cursor-staleness.utils.test.ts new file mode 100644 index 0000000..1ff67d2 --- /dev/null +++ b/src/utils/test/indexer-cursor-staleness.utils.test.ts @@ -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); + }); +});