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: 1 addition & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import router from './modules/index';
import { corsMiddleware } from './middlewares/cors.middleware';
import helmet from 'helmet';
import morgan from 'morgan';
import tspecOptions from './tspec.config';``
import tspecOptions from './tspec.config';
import { SendMail } from './utils/mail.utils';
import { appRateLimit } from './middlewares/rate.middleware';
import { requestIdMiddleware } from './middlewares/request-id.middleware';
Expand Down
41 changes: 41 additions & 0 deletions src/constants/creator-public-cache.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Cache settings for creator-facing public routes (CDN/browser caching).
*
* Reuses shared TTLs from {@link PUBLIC_ENDPOINT_CACHE_SECONDS} where appropriate.
*/
import { PUBLIC_ENDPOINT_CACHE_SECONDS } from './public-endpoint-cache.constants';

/**
* Max-age (seconds) for public creator GET responses (list, profile, stats).
*/
export const CREATOR_PUBLIC_ROUTE_CACHE_MAX_AGE_SECONDS = {
publicRead: PUBLIC_ENDPOINT_CACHE_SECONDS.short,
} as const;

const publicReadSeconds = CREATOR_PUBLIC_ROUTE_CACHE_MAX_AGE_SECONDS.publicRead;

/**
* Options for {@link cacheControl} on creator public routes.
*/
export const CREATOR_PUBLIC_ROUTE_CACHE_PRESETS = {
creatorList: {
maxAge: publicReadSeconds,
type: 'public' as const,
},
creatorStats: {
maxAge: publicReadSeconds,
type: 'public' as const,
},
creatorProfile: {
maxAge: publicReadSeconds,
type: 'public' as const,
},
} as const;

/**
* Full `Cache-Control` header values for creator public routes
* (e.g. `res.setHeader('Cache-Control', ...)` or tests).
*/
export const CREATOR_PUBLIC_ROUTE_CACHE_CONTROL_HEADER = {
publicRead: `public, max-age=${publicReadSeconds}`,
} as const;
8 changes: 4 additions & 4 deletions src/modules/creator/creator.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import {
sendPaginatedSuccess,
sendSuccess,
sendError,
sendValidationError,
ErrorCode,
Expand All @@ -11,6 +11,7 @@ import { getPaginatedCreators } from './creator.service';
import { parseCreatorSortOptions } from './creator.utils';
import { safeIntParam } from '../../utils/query.utils';
import { parsePublicQuery } from '../../utils/public-query-parse.utils';
import { wrapPublicCreatorListResponse } from '../creators/public-creator-list-envelope.utils';
import {
DEFAULT_PAGE,
DEFAULT_PAGE_SIZE,
Expand Down Expand Up @@ -51,10 +52,9 @@ export async function listCreators(req: Request, res: Response) {
sort,
});

return sendPaginatedSuccess(
return sendSuccess(
res,
creators,
meta,
wrapPublicCreatorListResponse(creators, meta),
200,
'Creators retrieved successfully'
);
Expand Down
14 changes: 12 additions & 2 deletions src/modules/creator/creator.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
upsertCreatorProfileHandler,
} from './creator-profile.handlers';
import { ROOT as CREATORS_ROOT } from '../../constants/creator.constants';
import { cacheControl } from '../../middlewares/cache-control.middleware';
import { CREATOR_PUBLIC_ROUTE_CACHE_PRESETS } from '../../constants/creator-public-cache.constants';

const router = Router();

Expand All @@ -22,14 +24,22 @@ const router = Router();
* @desc Get a paginated list of creators
* @access Public
*/
router.get(CREATORS_ROOT, listCreators);
router.get(
CREATORS_ROOT,
cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS.creatorList),
listCreators
);

/**
* @route GET /api/v1/creators/:creatorId/profile
* @desc Get creator profile scaffold payload
* @access Public
*/
router.get('/:creatorId/profile', getCreatorProfileHandler);
router.get(
'/:creatorId/profile',
cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS.creatorProfile),
getCreatorProfileHandler
);

/**
* @route PUT /api/v1/creators/:creatorId/profile
Expand Down
12 changes: 6 additions & 6 deletions src/modules/creators/creators.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
serializeCreatorList,
CreatorListResponse,
} from './creators.serializers';
import { wrapPublicCreatorListResponse } from './public-creator-list-envelope.utils';
import { mapPublicCreatorStats } from './creators.stats';
import {
sendSuccess,
Expand All @@ -31,15 +32,14 @@ export const httpListCreators: AsyncController = async (req, res, next) => {
// Fetch creators and total count
const [creators, total] = await fetchCreatorList(validatedQuery);

// Serialize response
const response: CreatorListResponse = {
creators: serializeCreatorList(creators),
pagination: buildOffsetPaginationMeta({
const response: CreatorListResponse = wrapPublicCreatorListResponse(
serializeCreatorList(creators),
buildOffsetPaginationMeta({
limit: validatedQuery.limit,
offset: validatedQuery.offset,
total,
}),
};
})
);

sendSuccess(res, response);
} catch (error) {
Expand Down
32 changes: 32 additions & 0 deletions src/modules/creators/creators.query-string.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { z, ZodTypeAny } from 'zod';

/**
* Trims supported creator list query string inputs before validation.
*
* - String values: leading/trailing whitespace removed; whitespace-only becomes `undefined`
* so optional defaults apply consistently with omitted params.
* - `null` / `undefined`: passed through as `undefined`.
* - Other types: returned unchanged (downstream Zod rules apply).
*
* Scope is intentionally narrow: no case folding, collapsing, or handle normalization here.
*/
export function normalizeCreatorListQueryStringValue(value: unknown): unknown {
if (value === null || value === undefined) {
return undefined;
}
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
return trimmed === '' ? undefined : trimmed;
}

/**
* Wraps a Zod schema with {@link normalizeCreatorListQueryStringValue} preprocessing.
* Use for creator list string query fields shared across list endpoints.
*/
export function withCreatorListQueryStringNormalization<T extends ZodTypeAny>(
schema: T
) {
return z.preprocess(normalizeCreatorListQueryStringValue, schema);
}
4 changes: 2 additions & 2 deletions src/modules/creators/creators.routes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { httpListCreators } from './creators.controllers';
import { cacheControl } from '../../middlewares/cache-control.middleware';
import { PUBLIC_ENDPOINT_CACHE_PRESETS } from '../../constants/public-endpoint-cache.constants';
import { CREATOR_PUBLIC_ROUTE_CACHE_PRESETS } from '../../constants/creator-public-cache.constants';

const creatorsRouter = Router();

Expand All @@ -13,7 +13,7 @@ const creatorsRouter = Router();
*/
creatorsRouter.get(
'/',
cacheControl(PUBLIC_ENDPOINT_CACHE_PRESETS.short),
cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS.creatorList),
httpListCreators
);

Expand Down
39 changes: 18 additions & 21 deletions src/modules/creators/creators.schemas.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import { z } from 'zod';
import {
CREATOR_LIST_SORT_OPTIONS,
CREATOR_LIST_SORT_ORDERS,
} from './creators.sort';
import { CREATOR_LIST_SORT_OPTIONS } from './creators.sort';
import { creatorListSortDirectionQueryParam } from './creators.sort-direction.parse';
import { withCreatorListQueryStringNormalization } from './creators.query-string.utils';
import { safeIntParam } from '../../utils/query.utils';
import {
DEFAULT_PAGE_SIZE,
DEFAULT_OFFSET,
MIN_PAGE_SIZE,
MAX_PAGE_SIZE,
} from '../../constants/pagination.constants';
import {
DEFAULT_CREATOR_LIST_SORT,
DEFAULT_CREATOR_LIST_ORDER,
} from '../../constants/creator-list-sort.constants';
import { DEFAULT_CREATOR_LIST_SORT } from '../../constants/creator-list-sort.constants';

/**
* Validation schema for creator list query parameters.
Expand All @@ -40,21 +36,22 @@ export const CreatorListQuerySchema = z.object({
}),

// Sorting
sort: z.enum(CREATOR_LIST_SORT_OPTIONS).optional().default(DEFAULT_CREATOR_LIST_SORT),
order: z
.enum(CREATOR_LIST_SORT_ORDERS)
.optional()
.default(DEFAULT_CREATOR_LIST_ORDER),
sort: withCreatorListQueryStringNormalization(
z.enum(CREATOR_LIST_SORT_OPTIONS).optional().default(DEFAULT_CREATOR_LIST_SORT)
),
order: creatorListSortDirectionQueryParam(),

// Filters
verified: z
.string()
.optional()
.transform(val => {
if (val === undefined) return undefined;
return val === 'true';
}),
search: z.string().optional(),
verified: withCreatorListQueryStringNormalization(
z
.string()
.optional()
.transform(val => {
if (val === undefined) return undefined;
return val === 'true';
})
),
search: withCreatorListQueryStringNormalization(z.string().optional()),
});

export type CreatorListQueryType = z.infer<typeof CreatorListQuerySchema>;
17 changes: 7 additions & 10 deletions src/modules/creators/creators.serializers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { CreatorProfile } from '../../types/profile.types';
import type { OffsetPaginationMeta } from '../../utils/pagination.utils';
import type { PublicCreatorListEnvelope } from './public-creator-list-envelope.utils';

/**
* Creator summary shape for list responses.
Expand Down Expand Up @@ -51,14 +53,9 @@ export function serializeCreatorList(
}

/**
* Paginated creator list response shape.
* Paginated creator list response body (offset pagination metadata).
*/
export interface CreatorListResponse {
creators: CreatorSummary[];
pagination: {
limit: number;
offset: number;
total: number;
hasMore: boolean;
};
}
export type CreatorListResponse = PublicCreatorListEnvelope<
CreatorSummary,
OffsetPaginationMeta
>;
25 changes: 25 additions & 0 deletions src/modules/creators/creators.sort-direction.parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { z } from 'zod';
import {
CREATOR_LIST_SORT_ORDERS,
type CreatorListSortOrder,
} from './creators.sort';
import { DEFAULT_CREATOR_LIST_ORDER } from '../../constants/creator-list-sort.constants';
import { normalizeCreatorListQueryStringValue } from './creators.query-string.utils';

const creatorListSortDirectionEnum = z.enum(CREATOR_LIST_SORT_ORDERS);

/**
* Zod schema for the creator list `order` query parameter (sort direction).
*
* - Only `asc` and `desc` are accepted (see {@link CREATOR_LIST_SORT_ORDERS}).
* - Omitted or empty values use {@link DEFAULT_CREATOR_LIST_ORDER}.
* - Invalid values fail parse so {@link parsePublicQuery} returns structured validation errors.
*/
export function creatorListSortDirectionQueryParam(
defaultOrder: CreatorListSortOrder = DEFAULT_CREATOR_LIST_ORDER
) {
return z.preprocess(
normalizeCreatorListQueryStringValue,
creatorListSortDirectionEnum.optional().default(defaultOrder)
);
}
13 changes: 7 additions & 6 deletions src/modules/creators/creators.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CreatorProfile } from '../../types/profile.types';
import { CreatorListQueryType } from './creators.schemas';
import { mapCreatorListSort } from './creators.sort';
import { CreatorListResponse } from './creators.serializers';
import { wrapPublicCreatorListResponse } from './public-creator-list-envelope.utils';
import { buildOffsetPaginationMeta } from '../../utils/pagination.utils';

type CreatorListWhere = {
Expand Down Expand Up @@ -65,17 +66,17 @@ export async function fetchCreatorList(
*
* @example
* const emptyResponse = createEmptyCreatorListResponse(validatedQuery);
* // Returns: { creators: [], pagination: { limit, offset, total: 0, hasMore: false } }
* // Returns: { items: [], meta: { limit, offset, total: 0, hasMore: false } }
*/
export function createEmptyCreatorListResponse(
query: CreatorListQueryType
): CreatorListResponse {
return {
creators: [],
pagination: buildOffsetPaginationMeta({
return wrapPublicCreatorListResponse(
[],
buildOffsetPaginationMeta({
limit: query.limit,
offset: query.offset,
total: 0,
}),
};
})
);
}
20 changes: 20 additions & 0 deletions src/modules/creators/public-creator-list-envelope.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Standard body shape for public creator list success payloads
* (typically nested under `data` via {@link sendSuccess}).
*
* `meta` holds route-specific pagination or list metadata (offset-based, page-based, etc.).
*/
export type PublicCreatorListEnvelope<TItem, TMeta> = {
items: TItem[];
meta: TMeta;
};

/**
* Wraps list results and metadata in a single predictable object for public list routes.
*/
export function wrapPublicCreatorListResponse<TItem, TMeta>(
items: TItem[],
meta: TMeta
): PublicCreatorListEnvelope<TItem, TMeta> {
return { items, meta };
}
4 changes: 4 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
"allowJs": true,
"outDir": "dist",
"rootDir": "src",
"baseUrl": ".",
"paths": {
"@prisma/client": ["./node_modules/.prisma/client/index.d.ts"]
},
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
Expand Down
Loading