diff --git a/src/app.ts b/src/app.ts index 24915bc..650a76b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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'; diff --git a/src/modules/creator/README.md b/src/modules/creator/README.md new file mode 100644 index 0000000..fc5b12f --- /dev/null +++ b/src/modules/creator/README.md @@ -0,0 +1,40 @@ +# Creator Module + +Initial scaffold for creator-facing API surfaces. + +## Route structure + +All routes are mounted under `/api/v1/creators`. + +- `GET /` — existing paginated creator list. +- `GET /:creatorId/profile` — placeholder creator profile read endpoint. +- `PUT /:creatorId/profile` — placeholder creator profile write endpoint. + +## Handler surface (scaffold) + +### Read profile (`GET /:creatorId/profile`) + +- Validates `creatorId` path parameter. +- Returns explicit placeholder response shape: + - `creatorId` + - `displayName` + - `bio` + - `avatarUrl` + - `links[]` + - `metadata.source` and `metadata.isProfileComplete` + +### Write profile (`PUT /:creatorId/profile`) + +- Validates `creatorId` path parameter. +- Validates payload fields: + - `displayName` + - `bio` + - `avatarUrl` + - `links[]` (`label`, `url`) +- Returns `202 Accepted` with validated payload echo + placeholder metadata. + +## Notes for follow-up issues + +- Authentication and authorization are intentionally deferred. +- Persistence/indexing integration is intentionally deferred. +- Current handlers are designed so storage/indexing can be added without changing route contracts. diff --git a/src/modules/creator/creator-profile.handlers.ts b/src/modules/creator/creator-profile.handlers.ts new file mode 100644 index 0000000..94579db --- /dev/null +++ b/src/modules/creator/creator-profile.handlers.ts @@ -0,0 +1,99 @@ +import { Request, Response } from 'express'; +import { + sendError, + sendSuccess, + sendValidationError, + ErrorCode, +} from '../../utils/api-response.utils'; +import { + CreatorProfileParamsSchema, + UpsertCreatorProfileBodySchema, +} from './creator-profile.schemas'; +import { + getCreatorProfile, + upsertCreatorProfile, +} from './creator-profile.service'; + +/** + * @route GET /api/v1/creators/:creatorId/profile + * @desc Placeholder creator profile read endpoint + * @access Public (for scaffold only) + */ +export async function getCreatorProfileHandler(req: Request, res: Response) { + try { + const paramsResult = CreatorProfileParamsSchema.safeParse(req.params); + if (!paramsResult.success) { + return sendValidationError( + res, + 'Invalid creator profile path parameters', + paramsResult.error.issues.map(issue => ({ + field: issue.path.join('.'), + message: issue.message, + })) + ); + } + + const profile = await getCreatorProfile(paramsResult.data.creatorId); + return sendSuccess(res, profile, 200, 'Creator profile retrieved'); + } catch (error) { + console.error('Error retrieving creator profile:', error); + return sendError( + res, + 500, + ErrorCode.INTERNAL_ERROR, + 'Failed to retrieve creator profile' + ); + } +} + +/** + * @route PUT /api/v1/creators/:creatorId/profile + * @desc Placeholder creator profile write endpoint + * @access Auth will be required in a follow-up issue + */ +export async function upsertCreatorProfileHandler(req: Request, res: Response) { + try { + const paramsResult = CreatorProfileParamsSchema.safeParse(req.params); + if (!paramsResult.success) { + return sendValidationError( + res, + 'Invalid creator profile path parameters', + paramsResult.error.issues.map(issue => ({ + field: issue.path.join('.'), + message: issue.message, + })) + ); + } + + const bodyResult = UpsertCreatorProfileBodySchema.safeParse(req.body); + if (!bodyResult.success) { + return sendValidationError( + res, + 'Invalid creator profile payload', + bodyResult.error.issues.map(issue => ({ + field: issue.path.join('.'), + message: issue.message, + })) + ); + } + + const profile = await upsertCreatorProfile( + paramsResult.data.creatorId, + bodyResult.data + ); + return sendSuccess( + res, + profile, + 202, + 'Creator profile write accepted (placeholder)' + ); + } catch (error) { + console.error('Error upserting creator profile:', error); + return sendError( + res, + 500, + ErrorCode.INTERNAL_ERROR, + 'Failed to upsert creator profile' + ); + } +} diff --git a/src/modules/creator/creator-profile.schemas.ts b/src/modules/creator/creator-profile.schemas.ts new file mode 100644 index 0000000..69f7aec --- /dev/null +++ b/src/modules/creator/creator-profile.schemas.ts @@ -0,0 +1,75 @@ +import { z } from 'zod'; + +/** + * Shared creator profile identifier schema for route params. + * + * We use a conservative format now (UUID-like or CUID-like IDs can be added later) + * and keep this centralized for future route extensions. + */ +export const CreatorProfileParamsSchema = z.object({ + creatorId: z + .string() + .trim() + .min(1, 'Creator ID is required') + .max(128, 'Creator ID is too long'), +}); + +/** + * Placeholder read response shape for GET /api/v1/creators/:creatorId/profile. + * + * The shape is explicit now so future indexing-backed values can be dropped in + * without changing API contracts. + */ +export const CreatorProfileReadResponseSchema = z.object({ + creatorId: z.string(), + displayName: z.string().nullable(), + bio: z.string().nullable(), + avatarUrl: z.string().url().nullable(), + links: z.array(z.object({ label: z.string(), url: z.string().url() })), + metadata: z.object({ + source: z.enum(['placeholder']), + isProfileComplete: z.boolean(), + }), +}); + +/** + * Placeholder write payload for PUT /api/v1/creators/:creatorId/profile. + * + * Validation is intentionally strict and explicit so the eventual persistence layer + * can safely trust handler inputs. + */ +export const UpsertCreatorProfileBodySchema = z.object({ + displayName: z + .string() + .trim() + .min(2, 'Display name must be at least 2 characters') + .max(80, 'Display name must be at most 80 characters') + .optional(), + bio: z + .string() + .trim() + .max(1000, 'Bio must be at most 1000 characters') + .optional(), + avatarUrl: z.string().trim().url('Avatar URL must be a valid URL').optional(), + links: z + .array( + z.object({ + label: z + .string() + .trim() + .min(1, 'Link label is required') + .max(40, 'Link label must be at most 40 characters'), + url: z.string().trim().url('Link URL must be a valid URL'), + }) + ) + .max(8, 'At most 8 profile links are allowed') + .optional(), +}); + +export type CreatorProfileParams = z.infer; +export type CreatorProfileReadResponse = z.infer< + typeof CreatorProfileReadResponseSchema +>; +export type UpsertCreatorProfileBody = z.infer< + typeof UpsertCreatorProfileBodySchema +>; diff --git a/src/modules/creator/creator-profile.service.ts b/src/modules/creator/creator-profile.service.ts new file mode 100644 index 0000000..416f248 --- /dev/null +++ b/src/modules/creator/creator-profile.service.ts @@ -0,0 +1,50 @@ +import { + CreatorProfileReadResponse, + UpsertCreatorProfileBody, +} from './creator-profile.schemas'; + +/** + * Placeholder profile read service. + * + * TODO(accesslayer): Replace this placeholder source with database/indexing-backed + * reads in a follow-up issue. + */ +export async function getCreatorProfile( + creatorId: string +): Promise { + return { + creatorId, + displayName: null, + bio: null, + avatarUrl: null, + links: [], + metadata: { + source: 'placeholder', + isProfileComplete: false, + }, + }; +} + +/** + * Placeholder profile upsert service. + * + * TODO(accesslayer): Wire this to authenticated profile persistence when + * creator identity and ownership rules are finalized. + */ +export async function upsertCreatorProfile( + creatorId: string, + payload: UpsertCreatorProfileBody +): Promise<{ + creatorId: string; + acceptedProfile: UpsertCreatorProfileBody; + metadata: { source: 'placeholder'; persisted: false }; +}> { + return { + creatorId, + acceptedProfile: payload, + metadata: { + source: 'placeholder', + persisted: false, + }, + }; +} diff --git a/src/modules/creator/creator.routes.ts b/src/modules/creator/creator.routes.ts index d75252c..95c6263 100644 --- a/src/modules/creator/creator.routes.ts +++ b/src/modules/creator/creator.routes.ts @@ -1,10 +1,22 @@ // src/modules/creator/creator.routes.ts import { Router } from 'express'; import { listCreators } from './creator.controller'; +import { + getCreatorProfileHandler, + upsertCreatorProfileHandler, +} from './creator-profile.handlers'; import { ROOT as CREATORS_ROOT } from '../../constants/creator.constants'; const router = Router(); +/** + * Creator module route map (initial scaffold): + * + * - GET /api/v1/creators + * - GET /api/v1/creators/:creatorId/profile + * - PUT /api/v1/creators/:creatorId/profile + */ + /** * @route GET /api/v1/creators * @desc Get a paginated list of creators @@ -12,4 +24,18 @@ const router = Router(); */ router.get(CREATORS_ROOT, listCreators); +/** + * @route GET /api/v1/creators/:creatorId/profile + * @desc Get creator profile scaffold payload + * @access Public + */ +router.get('/:creatorId/profile', getCreatorProfileHandler); + +/** + * @route PUT /api/v1/creators/:creatorId/profile + * @desc Upsert creator profile scaffold payload + * @access Public for scaffold (auth follow-up required) + */ +router.put('/:creatorId/profile', upsertCreatorProfileHandler); + export default router; diff --git a/src/modules/creators/creators.controllers.ts b/src/modules/creators/creators.controllers.ts index 5c28101..e9fadcf 100644 --- a/src/modules/creators/creators.controllers.ts +++ b/src/modules/creators/creators.controllers.ts @@ -11,6 +11,7 @@ import { sendValidationError, } from '../../utils/api-response.utils'; import { parsePublicQuery } from '../../utils/public-query-parse.utils'; +import { buildOffsetPaginationMeta } from '../../utils/pagination.utils'; /** * Controller for GET /api/v1/creators @@ -33,12 +34,11 @@ export const httpListCreators: AsyncController = async (req, res, next) => { // Serialize response const response: CreatorListResponse = { creators: serializeCreatorList(creators), - pagination: { + pagination: buildOffsetPaginationMeta({ limit: validatedQuery.limit, offset: validatedQuery.offset, total, - hasMore: validatedQuery.offset + validatedQuery.limit < total, - }, + }), }; sendSuccess(res, response); diff --git a/src/modules/creators/creators.utils.ts b/src/modules/creators/creators.utils.ts index 5bafe79..f2ee0d4 100644 --- a/src/modules/creators/creators.utils.ts +++ b/src/modules/creators/creators.utils.ts @@ -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 { buildOffsetPaginationMeta } from '../../utils/pagination.utils'; type CreatorListWhere = { isVerified?: boolean; @@ -71,11 +72,10 @@ export function createEmptyCreatorListResponse( ): CreatorListResponse { return { creators: [], - pagination: { + pagination: buildOffsetPaginationMeta({ limit: query.limit, offset: query.offset, total: 0, - hasMore: false, - }, + }), }; } diff --git a/src/server.ts b/src/server.ts index 2a1aaca..d5e5c27 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,11 @@ import app from './app'; import { envConfig } from './config'; import { logger } from './utils/logger.utils'; import { prisma } from './utils/prisma.utils'; +import dotenv from 'dotenv' + + +dotenv.config() + async function startServer() { try { diff --git a/src/utils/pagination.utils.ts b/src/utils/pagination.utils.ts new file mode 100644 index 0000000..a638d98 --- /dev/null +++ b/src/utils/pagination.utils.ts @@ -0,0 +1,67 @@ +export type PaginationMeta = { + page: number; + pageSize: number; + totalItems: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; +}; + +export type PaginationMetaParams = { + page: number; + pageSize: number; + totalItems: number; +}; + +export type OffsetPaginationMeta = { + limit: number; + offset: number; + total: number; + hasMore: boolean; +}; + +export type OffsetPaginationMetaParams = { + limit: number; + offset: number; + total: number; +}; + +export const buildPaginationMeta = ({ + page, + pageSize, + totalItems, +}: PaginationMetaParams): PaginationMeta => { + const safePageSize = Math.max(1, Math.floor(pageSize)); + const safeTotalItems = Math.max(0, Math.floor(totalItems)); + const totalPages = Math.ceil(safeTotalItems / safePageSize); + const safePage = + totalPages === 0 + ? 1 + : Math.min(totalPages, Math.max(1, Math.floor(page))); + + return { + page: safePage, + pageSize: safePageSize, + totalItems: safeTotalItems, + totalPages, + hasNextPage: safePage < totalPages, + hasPreviousPage: safePage > 1, + }; +}; + +export const buildOffsetPaginationMeta = ({ + limit, + offset, + total, +}: OffsetPaginationMetaParams): OffsetPaginationMeta => { + const safeLimit = Math.max(1, Math.floor(limit)); + const safeOffset = Math.max(0, Math.floor(offset)); + const safeTotal = Math.max(0, Math.floor(total)); + + return { + limit: safeLimit, + offset: safeOffset, + total: safeTotal, + hasMore: safeOffset + safeLimit < safeTotal, + }; +};