diff --git a/prisma/schema/user.prisma b/prisma/schema/user.prisma index c33571d..a165eb4 100644 --- a/prisma/schema/user.prisma +++ b/prisma/schema/user.prisma @@ -10,7 +10,10 @@ model User { phoneNumber String? avatar String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + creatorProfile CreatorProfile? stellarWallet StellarWallet? creatorProfile CreatorProfile? -} \ No newline at end of file +} diff --git a/src/middlewares/cache-control.middleware.ts b/src/middlewares/cache-control.middleware.ts new file mode 100644 index 0000000..f4a63a7 --- /dev/null +++ b/src/middlewares/cache-control.middleware.ts @@ -0,0 +1,114 @@ +// src/middlewares/cache-control.middleware.ts +import { Request, Response, NextFunction } from 'express'; + +/** + * Cache control options for different types of endpoints. + */ +export interface CacheControlOptions { + /** + * Max age in seconds. Default: 300 (5 minutes) + */ + maxAge?: number; + /** + * Whether the cache is public (CDN can cache) or private (browser only). + * Default: 'public' + */ + type?: 'public' | 'private'; + /** + * Whether to include must-revalidate directive. + * Default: false + */ + mustRevalidate?: boolean; + /** + * Whether to include no-cache directive (requires revalidation). + * Default: false + */ + noCache?: boolean; + /** + * Whether to disable caching entirely. + * Default: false + */ + noStore?: boolean; +} + +/** + * Middleware factory that adds Cache-Control headers to responses. + * + * Applies only to GET requests to avoid caching mutations. + * Keeps cache behavior explicit and easy to understand in code. + * + * @param options - Cache control configuration + * + * @example + * // Public endpoint with 5-minute cache + * router.get('/creators', cacheControl({ maxAge: 300 }), listCreators); + * + * @example + * // No caching for sensitive data + * router.get('/profile', cacheControl({ noStore: true }), getProfile); + */ +export function cacheControl(options: CacheControlOptions = {}) { + const { + maxAge = 300, + type = 'public', + mustRevalidate = false, + noCache = false, + noStore = false, + } = options; + + return (req: Request, res: Response, next: NextFunction): void => { + // Only apply cache headers to GET requests + // Mutation routes (POST, PUT, DELETE, PATCH) remain unaffected + if (req.method !== 'GET') { + return next(); + } + + // Build Cache-Control header value + const directives: string[] = []; + + if (noStore) { + directives.push('no-store'); + } else if (noCache) { + directives.push('no-cache'); + } else { + directives.push(type); + directives.push(`max-age=${maxAge}`); + if (mustRevalidate) { + directives.push('must-revalidate'); + } + } + + res.setHeader('Cache-Control', directives.join(', ')); + next(); + }; +} + +/** + * Preset cache configurations for common use cases. + */ +export const CachePresets = { + /** + * Short cache for frequently updated public data (5 minutes) + */ + publicShort: { maxAge: 300, type: 'public' as const }, + + /** + * Medium cache for moderately stable public data (1 hour) + */ + publicMedium: { maxAge: 3600, type: 'public' as const }, + + /** + * Long cache for stable public data (24 hours) + */ + publicLong: { maxAge: 86400, type: 'public' as const }, + + /** + * Private cache for user-specific data (5 minutes) + */ + private: { maxAge: 300, type: 'private' as const }, + + /** + * No caching for sensitive or dynamic data + */ + noCache: { noStore: true }, +}; diff --git a/src/modules/creators/creators.controllers.ts b/src/modules/creators/creators.controllers.ts new file mode 100644 index 0000000..8036021 --- /dev/null +++ b/src/modules/creators/creators.controllers.ts @@ -0,0 +1,50 @@ +import { AsyncController } from '../../types/auth.types'; +import { CreatorListQuerySchema } from './creators.schemas'; +import { fetchCreatorList } from './creators.utils'; +import { + serializeCreatorList, + CreatorListResponse, +} from './creators.serializers'; +import { + sendSuccess, + sendValidationError, +} from '../../utils/api-response.utils'; +import { ZodError } from 'zod'; + +/** + * Controller for GET /api/v1/creators + * + * Returns paginated list of creator profiles with summary information. + * Validates query parameters and applies caching via middleware. + */ +export const httpListCreators: AsyncController = async (req, res, next) => { + try { + // Validate query parameters + const validatedQuery = CreatorListQuerySchema.parse(req.query); + + // Fetch creators and total count + const [creators, total] = await fetchCreatorList(validatedQuery); + + // Serialize response + const response: CreatorListResponse = { + creators: serializeCreatorList(creators), + pagination: { + limit: validatedQuery.limit, + offset: validatedQuery.offset, + total, + hasMore: validatedQuery.offset + validatedQuery.limit < total, + }, + }; + + sendSuccess(res, response); + } catch (error) { + if (error instanceof ZodError) { + const details = error.errors.map(err => ({ + field: err.path.join('.'), + message: err.message, + })); + return sendValidationError(res, 'Invalid query parameters', details); + } + next(error); + } +}; diff --git a/src/modules/creators/creators.routes.ts b/src/modules/creators/creators.routes.ts new file mode 100644 index 0000000..5be776d --- /dev/null +++ b/src/modules/creators/creators.routes.ts @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import { httpListCreators } from './creators.controllers'; +import { + cacheControl, + CachePresets, +} from '../../middlewares/cache-control.middleware'; + +const creatorsRouter = Router(); + +/** + * GET /api/v1/creators + * + * List all creators with pagination and filtering. + * Public endpoint with 5-minute cache. + */ +creatorsRouter.get( + '/', + cacheControl(CachePresets.publicShort), + httpListCreators +); + +export default creatorsRouter; diff --git a/src/modules/creators/creators.schemas.ts b/src/modules/creators/creators.schemas.ts new file mode 100644 index 0000000..f6e6f21 --- /dev/null +++ b/src/modules/creators/creators.schemas.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; + +/** + * Validation schema for creator list query parameters. + * + * Validates pagination and filter params for GET /api/v1/creators endpoint. + * Keeps query validation centralized and reusable across creator list handlers. + * + * @example + * GET /api/v1/creators?limit=20&offset=0&sort=createdAt&order=desc&verified=true + */ +export const CreatorListQuerySchema = z.object({ + // Pagination + limit: z + .string() + .optional() + .default('20') + .transform(val => parseInt(val, 10)) + .refine(val => val > 0 && val <= 100, { + message: 'Limit must be between 1 and 100', + }), + offset: z + .string() + .optional() + .default('0') + .transform(val => parseInt(val, 10)) + .refine(val => val >= 0, { + message: 'Offset must be non-negative', + }), + + // Sorting + sort: z + .enum(['createdAt', 'updatedAt', 'displayName', 'handle']) + .optional() + .default('createdAt'), + order: z.enum(['asc', 'desc']).optional().default('desc'), + + // Filters + verified: z + .string() + .optional() + .transform(val => { + if (val === undefined) return undefined; + return val === 'true'; + }), + search: z.string().optional(), +}); + +export type CreatorListQueryType = z.infer; diff --git a/src/modules/creators/creators.serializers.ts b/src/modules/creators/creators.serializers.ts new file mode 100644 index 0000000..cc97bfe --- /dev/null +++ b/src/modules/creators/creators.serializers.ts @@ -0,0 +1,64 @@ +import { CreatorProfile } from '../../types/profile.types'; + +/** + * Creator summary shape for list responses. + * + * Keeps full profile fields out of the list serializer to reduce payload size. + * Only includes essential information needed for creator listings. + */ +export interface CreatorSummary { + id: string; + handle: string; + displayName: string; + avatarUrl?: string; + isVerified: boolean; +} + +/** + * Serializes a full CreatorProfile into a CreatorSummary for list responses. + * + * Centralizes list serialization logic and keeps it reusable across endpoints. + * + * @param profile - Full creator profile from database + * @returns Creator summary suitable for list responses + * + * @example + * const summary = serializeCreatorSummary(creatorProfile); + * // Returns: { id, handle, displayName, avatarUrl, isVerified } + */ +export function serializeCreatorSummary( + profile: CreatorProfile +): CreatorSummary { + return { + id: profile.id, + handle: profile.handle, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + isVerified: profile.isVerified, + }; +} + +/** + * Serializes multiple creator profiles for list responses. + * + * @param profiles - Array of full creator profiles + * @returns Array of creator summaries + */ +export function serializeCreatorList( + profiles: CreatorProfile[] +): CreatorSummary[] { + return profiles.map(serializeCreatorSummary); +} + +/** + * Paginated creator list response shape. + */ +export interface CreatorListResponse { + creators: CreatorSummary[]; + pagination: { + limit: number; + offset: number; + total: number; + hasMore: boolean; + }; +} diff --git a/src/modules/creators/creators.utils.ts b/src/modules/creators/creators.utils.ts new file mode 100644 index 0000000..1404333 --- /dev/null +++ b/src/modules/creators/creators.utils.ts @@ -0,0 +1,53 @@ +import { prisma } from '../../utils/prisma.utils'; +import { CreatorProfile } from '../../types/profile.types'; +import { CreatorListQueryType } from './creators.schemas'; + +type CreatorListWhere = { + isVerified?: boolean; + OR?: Array<{ + handle?: { contains: string; mode: 'insensitive' }; + displayName?: { contains: string; mode: 'insensitive' }; + }>; +}; + +/** + * Fetch paginated list of creators from the database. + * + * @param query - Validated query parameters for pagination and filtering + * @returns Tuple of [creators, total count] + */ +export async function fetchCreatorList( + query: CreatorListQueryType +): 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; + } + + if (search) { + where.OR = [ + { handle: { contains: search, mode: 'insensitive' } }, + { displayName: { contains: search, mode: 'insensitive' } }, + ]; + } + + // Build order by clause + const orderBy = { [sort]: order }; + + // Fetch creators and total count in parallel + const [creators, total] = await Promise.all([ + prisma.creatorProfile.findMany({ + where, + orderBy, + skip: offset, + take: limit, + }), + prisma.creatorProfile.count({ where }), + ]); + + return [creators as CreatorProfile[], total]; +}