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
5 changes: 4 additions & 1 deletion prisma/schema/user.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ model User {
phoneNumber String?

avatar String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

creatorProfile CreatorProfile?
stellarWallet StellarWallet?
creatorProfile CreatorProfile?
}
}
114 changes: 114 additions & 0 deletions src/middlewares/cache-control.middleware.ts
Original file line number Diff line number Diff line change
@@ -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 },
};
50 changes: 50 additions & 0 deletions src/modules/creators/creators.controllers.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
22 changes: 22 additions & 0 deletions src/modules/creators/creators.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
49 changes: 49 additions & 0 deletions src/modules/creators/creators.schemas.ts
Original file line number Diff line number Diff line change
@@ -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<typeof CreatorListQuerySchema>;
64 changes: 64 additions & 0 deletions src/modules/creators/creators.serializers.ts
Original file line number Diff line number Diff line change
@@ -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;
};
}
53 changes: 53 additions & 0 deletions src/modules/creators/creators.utils.ts
Original file line number Diff line number Diff line change
@@ -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];
}
Loading