diff --git a/src/config.ts b/src/config.ts index ba942d9..e5343c8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -56,6 +56,7 @@ export const envSchema = z.object({ ENABLE_REQUEST_LOGGING: z.coerce.boolean().default(true), 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), }); export const envConfig = envSchema.parse(process.env); diff --git a/src/constants/creator-detail-include.constants.ts b/src/constants/creator-detail-include.constants.ts new file mode 100644 index 0000000..2f02623 --- /dev/null +++ b/src/constants/creator-detail-include.constants.ts @@ -0,0 +1,22 @@ +// src/constants/creator-detail-include.constants.ts +// Centralized default include-fields for creator detail reads. +// Route and service layers should reference these instead of inlining field lists. + +/** + * Prisma select fields returned for every creator detail read. + * Keeping this centralized ensures route, service, and test layers + * stay in sync without duplicating field lists. + */ +export const CREATOR_DETAIL_DEFAULT_SELECT = { + id: true, + handle: true, + displayName: true, + bio: true, + avatarUrl: true, + perks: true, + isVerified: true, + createdAt: true, + updatedAt: true, +} as const; + +export type CreatorDetailSelectKeys = keyof typeof CREATOR_DETAIL_DEFAULT_SELECT; diff --git a/src/modules/creator/creator-profile.service.ts b/src/modules/creator/creator-profile.service.ts index 0817deb..5072a80 100644 --- a/src/modules/creator/creator-profile.service.ts +++ b/src/modules/creator/creator-profile.service.ts @@ -3,6 +3,7 @@ import { CreatorProfileReadResponse, UpsertCreatorProfileBody, } from './creator-profile.schemas'; +import { CREATOR_DETAIL_DEFAULT_SELECT } from '../../constants/creator-detail-include.constants'; /** * Reads a creator profile from the database. @@ -16,6 +17,7 @@ export async function getCreatorProfile( where: { OR: [{ id: creatorId }, { handle: creatorId }], }, + select: CREATOR_DETAIL_DEFAULT_SELECT, }); if (!profile) { diff --git a/src/modules/creators/creators.filter.test.ts b/src/modules/creators/creators.filter.test.ts new file mode 100644 index 0000000..40112a8 --- /dev/null +++ b/src/modules/creators/creators.filter.test.ts @@ -0,0 +1,61 @@ +// src/modules/creators/creators.filter.test.ts +// Unit tests for creator list filter whitespace normalization. +// Asserts that parseCreatorFilters normalizes inputs consistently with +// the query parser rules defined in creators.query-string.utils.ts. + +import { strict as assert } from 'assert'; +import { parseCreatorFilters } from './creators.filter'; + +function run() { + // --- search: whitespace normalization --- + + // leading/trailing whitespace is trimmed + assert.deepEqual(parseCreatorFilters({ search: ' jazz ' }), { search: 'jazz' }); + + // internal whitespace collapses to a single space + assert.deepEqual(parseCreatorFilters({ search: 'jazz musician' }), { search: 'jazz musician' }); + + // tabs and newlines are treated as whitespace + assert.deepEqual(parseCreatorFilters({ search: '\t jazz \n' }), { search: 'jazz' }); + + // whitespace-only input is dropped (no search key in result) + assert.deepEqual(parseCreatorFilters({ search: ' ' }), {}); + + // empty string is dropped + assert.deepEqual(parseCreatorFilters({ search: '' }), {}); + + // undefined search is omitted + assert.deepEqual(parseCreatorFilters({}), {}); + + // normal search passes through unchanged + assert.deepEqual(parseCreatorFilters({ search: 'alice' }), { search: 'alice' }); + + // --- verified: coercion --- + + assert.deepEqual(parseCreatorFilters({ verified: 'true' }), { verified: true }); + assert.deepEqual(parseCreatorFilters({ verified: 'false' }), { verified: false }); + assert.deepEqual(parseCreatorFilters({ verified: true }), { verified: true }); + + // --- combined --- + + assert.deepEqual( + parseCreatorFilters({ verified: 'true', search: ' bob ' }), + { verified: true, search: 'bob' } + ); + + // --- unknown keys are rejected --- + + assert.throws( + () => parseCreatorFilters({ unknown: 'value' }), + /Unsupported creator filter key\(s\): unknown/ + ); + + assert.throws( + () => parseCreatorFilters({ verified: 'true', extra: '1' }), + /Unsupported creator filter key\(s\): extra/ + ); + + console.log('creators.filter whitespace normalization tests passed'); +} + +run(); diff --git a/src/modules/creators/creators.filter.ts b/src/modules/creators/creators.filter.ts index 880604f..89b377c 100644 --- a/src/modules/creators/creators.filter.ts +++ b/src/modules/creators/creators.filter.ts @@ -50,9 +50,9 @@ export function parseCreatorFilters( } if (typeof raw.search === 'string') { - const trimmed = raw.search.trim(); - if (trimmed.length > 0) { - result.search = trimmed; + const normalized = raw.search.trim().replace(/\s+/g, ' '); + if (normalized.length > 0) { + result.search = normalized; } } diff --git a/src/modules/creators/creators.utils.ts b/src/modules/creators/creators.utils.ts index b71c6a0..34fd051 100644 --- a/src/modules/creators/creators.utils.ts +++ b/src/modules/creators/creators.utils.ts @@ -5,6 +5,8 @@ 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; @@ -44,6 +46,7 @@ export async function fetchCreatorList( const orderBy = mapCreatorListSort(sort, order); // Fetch creators and total count in parallel + const start = Date.now(); const [creators, total] = await Promise.all([ prisma.creatorProfile.findMany({ where, @@ -54,6 +57,21 @@ export async function fetchCreatorList( prisma.creatorProfile.count({ where }), ]); + const durationMs = Date.now() - start; + if (durationMs > envConfig.CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS) { + logger.warn({ + msg: 'Slow creator list query', + durationMs, + thresholdMs: envConfig.CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS, + sort, + order, + hasSearch: !!normalizedSearch, + hasVerifiedFilter: verified !== undefined, + limit, + offset, + }); + } + return [creators as unknown as CreatorProfile[], total]; } diff --git a/src/utils/creator-feed-cursor.utils.ts b/src/utils/creator-feed-cursor.utils.ts new file mode 100644 index 0000000..c651e49 --- /dev/null +++ b/src/utils/creator-feed-cursor.utils.ts @@ -0,0 +1,63 @@ +// src/utils/creator-feed-cursor.utils.ts +// Decode and validate creator feed cursors in one place. +// All feed endpoint paths should use decodeCreatorFeedCursor instead of +// calling decodeCursor directly, so parse errors are consistent. + +import { decodeCursor, CursorChecksumError } from './cursor.utils'; + +/** + * Shape of a decoded creator feed cursor payload. + */ +export interface CreatorFeedCursorPayload { + /** ISO timestamp used as the pagination anchor */ + createdAt: string; + /** Tiebreaker ID for stable ordering */ + id: string; +} + +export type CreatorFeedCursorResult = + | { ok: true; payload: CreatorFeedCursorPayload } + | { ok: false; error: string }; + +/** + * Decode and validate a creator feed cursor string. + * + * Returns a discriminated union so callers can handle parse errors + * without catching exceptions. + * + * @param raw - Raw cursor string from query params + * @returns `{ ok: true, payload }` or `{ ok: false, error }` + * + * @example + * const result = decodeCreatorFeedCursor(req.query.cursor); + * if (!result.ok) return sendValidationError(res, result.error); + */ +export function decodeCreatorFeedCursor(raw: unknown): CreatorFeedCursorResult { + if (raw === undefined || raw === null || raw === '') { + return { ok: false, error: 'Cursor is required' }; + } + + if (typeof raw !== 'string') { + return { ok: false, error: 'Cursor must be a string' }; + } + + let payload: CreatorFeedCursorPayload; + try { + payload = decodeCursor(raw); + } catch (err) { + const message = + err instanceof CursorChecksumError ? err.message : 'Invalid cursor'; + return { ok: false, error: message }; + } + + if ( + typeof payload.createdAt !== 'string' || + typeof payload.id !== 'string' || + !payload.createdAt || + !payload.id + ) { + return { ok: false, error: 'Cursor payload is missing required fields' }; + } + + return { ok: true, payload }; +}