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
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions src/constants/creator-detail-include.constants.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions src/modules/creator/creator-profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -16,6 +17,7 @@ export async function getCreatorProfile(
where: {
OR: [{ id: creatorId }, { handle: creatorId }],
},
select: CREATOR_DETAIL_DEFAULT_SELECT,
});

if (!profile) {
Expand Down
61 changes: 61 additions & 0 deletions src/modules/creators/creators.filter.test.ts
Original file line number Diff line number Diff line change
@@ -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();
6 changes: 3 additions & 3 deletions src/modules/creators/creators.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
18 changes: 18 additions & 0 deletions src/modules/creators/creators.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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];
}

Expand Down
63 changes: 63 additions & 0 deletions src/utils/creator-feed-cursor.utils.ts
Original file line number Diff line number Diff line change
@@ -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<CreatorFeedCursorPayload>(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 };
}
Loading