Skip to content

Commit b89e91c

Browse files
authored
Merge pull request #24 from xqcxx/feat/creator-listing-13-19-21
feat: add creator list validation, serializer, and cache-control
2 parents 03bd659 + fc0e6d1 commit b89e91c

7 files changed

Lines changed: 356 additions & 1 deletion

File tree

prisma/schema/user.prisma

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ model User {
1010
phoneNumber String?
1111
1212
avatar String?
13+
createdAt DateTime @default(now())
14+
updatedAt DateTime @updatedAt
1315
16+
creatorProfile CreatorProfile?
1417
stellarWallet StellarWallet?
1518
creatorProfile CreatorProfile?
16-
}
19+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// src/middlewares/cache-control.middleware.ts
2+
import { Request, Response, NextFunction } from 'express';
3+
4+
/**
5+
* Cache control options for different types of endpoints.
6+
*/
7+
export interface CacheControlOptions {
8+
/**
9+
* Max age in seconds. Default: 300 (5 minutes)
10+
*/
11+
maxAge?: number;
12+
/**
13+
* Whether the cache is public (CDN can cache) or private (browser only).
14+
* Default: 'public'
15+
*/
16+
type?: 'public' | 'private';
17+
/**
18+
* Whether to include must-revalidate directive.
19+
* Default: false
20+
*/
21+
mustRevalidate?: boolean;
22+
/**
23+
* Whether to include no-cache directive (requires revalidation).
24+
* Default: false
25+
*/
26+
noCache?: boolean;
27+
/**
28+
* Whether to disable caching entirely.
29+
* Default: false
30+
*/
31+
noStore?: boolean;
32+
}
33+
34+
/**
35+
* Middleware factory that adds Cache-Control headers to responses.
36+
*
37+
* Applies only to GET requests to avoid caching mutations.
38+
* Keeps cache behavior explicit and easy to understand in code.
39+
*
40+
* @param options - Cache control configuration
41+
*
42+
* @example
43+
* // Public endpoint with 5-minute cache
44+
* router.get('/creators', cacheControl({ maxAge: 300 }), listCreators);
45+
*
46+
* @example
47+
* // No caching for sensitive data
48+
* router.get('/profile', cacheControl({ noStore: true }), getProfile);
49+
*/
50+
export function cacheControl(options: CacheControlOptions = {}) {
51+
const {
52+
maxAge = 300,
53+
type = 'public',
54+
mustRevalidate = false,
55+
noCache = false,
56+
noStore = false,
57+
} = options;
58+
59+
return (req: Request, res: Response, next: NextFunction): void => {
60+
// Only apply cache headers to GET requests
61+
// Mutation routes (POST, PUT, DELETE, PATCH) remain unaffected
62+
if (req.method !== 'GET') {
63+
return next();
64+
}
65+
66+
// Build Cache-Control header value
67+
const directives: string[] = [];
68+
69+
if (noStore) {
70+
directives.push('no-store');
71+
} else if (noCache) {
72+
directives.push('no-cache');
73+
} else {
74+
directives.push(type);
75+
directives.push(`max-age=${maxAge}`);
76+
if (mustRevalidate) {
77+
directives.push('must-revalidate');
78+
}
79+
}
80+
81+
res.setHeader('Cache-Control', directives.join(', '));
82+
next();
83+
};
84+
}
85+
86+
/**
87+
* Preset cache configurations for common use cases.
88+
*/
89+
export const CachePresets = {
90+
/**
91+
* Short cache for frequently updated public data (5 minutes)
92+
*/
93+
publicShort: { maxAge: 300, type: 'public' as const },
94+
95+
/**
96+
* Medium cache for moderately stable public data (1 hour)
97+
*/
98+
publicMedium: { maxAge: 3600, type: 'public' as const },
99+
100+
/**
101+
* Long cache for stable public data (24 hours)
102+
*/
103+
publicLong: { maxAge: 86400, type: 'public' as const },
104+
105+
/**
106+
* Private cache for user-specific data (5 minutes)
107+
*/
108+
private: { maxAge: 300, type: 'private' as const },
109+
110+
/**
111+
* No caching for sensitive or dynamic data
112+
*/
113+
noCache: { noStore: true },
114+
};
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { AsyncController } from '../../types/auth.types';
2+
import { CreatorListQuerySchema } from './creators.schemas';
3+
import { fetchCreatorList } from './creators.utils';
4+
import {
5+
serializeCreatorList,
6+
CreatorListResponse,
7+
} from './creators.serializers';
8+
import {
9+
sendSuccess,
10+
sendValidationError,
11+
} from '../../utils/api-response.utils';
12+
import { ZodError } from 'zod';
13+
14+
/**
15+
* Controller for GET /api/v1/creators
16+
*
17+
* Returns paginated list of creator profiles with summary information.
18+
* Validates query parameters and applies caching via middleware.
19+
*/
20+
export const httpListCreators: AsyncController = async (req, res, next) => {
21+
try {
22+
// Validate query parameters
23+
const validatedQuery = CreatorListQuerySchema.parse(req.query);
24+
25+
// Fetch creators and total count
26+
const [creators, total] = await fetchCreatorList(validatedQuery);
27+
28+
// Serialize response
29+
const response: CreatorListResponse = {
30+
creators: serializeCreatorList(creators),
31+
pagination: {
32+
limit: validatedQuery.limit,
33+
offset: validatedQuery.offset,
34+
total,
35+
hasMore: validatedQuery.offset + validatedQuery.limit < total,
36+
},
37+
};
38+
39+
sendSuccess(res, response);
40+
} catch (error) {
41+
if (error instanceof ZodError) {
42+
const details = error.errors.map(err => ({
43+
field: err.path.join('.'),
44+
message: err.message,
45+
}));
46+
return sendValidationError(res, 'Invalid query parameters', details);
47+
}
48+
next(error);
49+
}
50+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Router } from 'express';
2+
import { httpListCreators } from './creators.controllers';
3+
import {
4+
cacheControl,
5+
CachePresets,
6+
} from '../../middlewares/cache-control.middleware';
7+
8+
const creatorsRouter = Router();
9+
10+
/**
11+
* GET /api/v1/creators
12+
*
13+
* List all creators with pagination and filtering.
14+
* Public endpoint with 5-minute cache.
15+
*/
16+
creatorsRouter.get(
17+
'/',
18+
cacheControl(CachePresets.publicShort),
19+
httpListCreators
20+
);
21+
22+
export default creatorsRouter;
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { z } from 'zod';
2+
3+
/**
4+
* Validation schema for creator list query parameters.
5+
*
6+
* Validates pagination and filter params for GET /api/v1/creators endpoint.
7+
* Keeps query validation centralized and reusable across creator list handlers.
8+
*
9+
* @example
10+
* GET /api/v1/creators?limit=20&offset=0&sort=createdAt&order=desc&verified=true
11+
*/
12+
export const CreatorListQuerySchema = z.object({
13+
// Pagination
14+
limit: z
15+
.string()
16+
.optional()
17+
.default('20')
18+
.transform(val => parseInt(val, 10))
19+
.refine(val => val > 0 && val <= 100, {
20+
message: 'Limit must be between 1 and 100',
21+
}),
22+
offset: z
23+
.string()
24+
.optional()
25+
.default('0')
26+
.transform(val => parseInt(val, 10))
27+
.refine(val => val >= 0, {
28+
message: 'Offset must be non-negative',
29+
}),
30+
31+
// Sorting
32+
sort: z
33+
.enum(['createdAt', 'updatedAt', 'displayName', 'handle'])
34+
.optional()
35+
.default('createdAt'),
36+
order: z.enum(['asc', 'desc']).optional().default('desc'),
37+
38+
// Filters
39+
verified: z
40+
.string()
41+
.optional()
42+
.transform(val => {
43+
if (val === undefined) return undefined;
44+
return val === 'true';
45+
}),
46+
search: z.string().optional(),
47+
});
48+
49+
export type CreatorListQueryType = z.infer<typeof CreatorListQuerySchema>;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { CreatorProfile } from '../../types/profile.types';
2+
3+
/**
4+
* Creator summary shape for list responses.
5+
*
6+
* Keeps full profile fields out of the list serializer to reduce payload size.
7+
* Only includes essential information needed for creator listings.
8+
*/
9+
export interface CreatorSummary {
10+
id: string;
11+
handle: string;
12+
displayName: string;
13+
avatarUrl?: string;
14+
isVerified: boolean;
15+
}
16+
17+
/**
18+
* Serializes a full CreatorProfile into a CreatorSummary for list responses.
19+
*
20+
* Centralizes list serialization logic and keeps it reusable across endpoints.
21+
*
22+
* @param profile - Full creator profile from database
23+
* @returns Creator summary suitable for list responses
24+
*
25+
* @example
26+
* const summary = serializeCreatorSummary(creatorProfile);
27+
* // Returns: { id, handle, displayName, avatarUrl, isVerified }
28+
*/
29+
export function serializeCreatorSummary(
30+
profile: CreatorProfile
31+
): CreatorSummary {
32+
return {
33+
id: profile.id,
34+
handle: profile.handle,
35+
displayName: profile.displayName,
36+
avatarUrl: profile.avatarUrl,
37+
isVerified: profile.isVerified,
38+
};
39+
}
40+
41+
/**
42+
* Serializes multiple creator profiles for list responses.
43+
*
44+
* @param profiles - Array of full creator profiles
45+
* @returns Array of creator summaries
46+
*/
47+
export function serializeCreatorList(
48+
profiles: CreatorProfile[]
49+
): CreatorSummary[] {
50+
return profiles.map(serializeCreatorSummary);
51+
}
52+
53+
/**
54+
* Paginated creator list response shape.
55+
*/
56+
export interface CreatorListResponse {
57+
creators: CreatorSummary[];
58+
pagination: {
59+
limit: number;
60+
offset: number;
61+
total: number;
62+
hasMore: boolean;
63+
};
64+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { prisma } from '../../utils/prisma.utils';
2+
import { CreatorProfile } from '../../types/profile.types';
3+
import { CreatorListQueryType } from './creators.schemas';
4+
5+
type CreatorListWhere = {
6+
isVerified?: boolean;
7+
OR?: Array<{
8+
handle?: { contains: string; mode: 'insensitive' };
9+
displayName?: { contains: string; mode: 'insensitive' };
10+
}>;
11+
};
12+
13+
/**
14+
* Fetch paginated list of creators from the database.
15+
*
16+
* @param query - Validated query parameters for pagination and filtering
17+
* @returns Tuple of [creators, total count]
18+
*/
19+
export async function fetchCreatorList(
20+
query: CreatorListQueryType
21+
): Promise<[CreatorProfile[], number]> {
22+
const { limit, offset, sort, order, verified, search } = query;
23+
24+
// Build where clause for filters
25+
const where: CreatorListWhere = {};
26+
27+
if (verified !== undefined) {
28+
where.isVerified = verified;
29+
}
30+
31+
if (search) {
32+
where.OR = [
33+
{ handle: { contains: search, mode: 'insensitive' } },
34+
{ displayName: { contains: search, mode: 'insensitive' } },
35+
];
36+
}
37+
38+
// Build order by clause
39+
const orderBy = { [sort]: order };
40+
41+
// Fetch creators and total count in parallel
42+
const [creators, total] = await Promise.all([
43+
prisma.creatorProfile.findMany({
44+
where,
45+
orderBy,
46+
skip: offset,
47+
take: limit,
48+
}),
49+
prisma.creatorProfile.count({ where }),
50+
]);
51+
52+
return [creators as CreatorProfile[], total];
53+
}

0 commit comments

Comments
 (0)