From 132275f1c8a142f1482d622db2cb34c12ef072f6 Mon Sep 17 00:00:00 2001 From: buinntalen Date: Sat, 25 Apr 2026 17:41:52 +0000 Subject: [PATCH 1/4] fix(creators): add null-safe sort field resolver for creator queries Ensure nullable fields are sorted deterministically by using Prisma's nulls parameter to place null values last, keeping sort behavior predictable and consistent across mixed null/non-null datasets. Refs #145 --- src/modules/creators/creators.sort.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/creators/creators.sort.ts b/src/modules/creators/creators.sort.ts index 84f7616..06c2068 100644 --- a/src/modules/creators/creators.sort.ts +++ b/src/modules/creators/creators.sort.ts @@ -28,6 +28,7 @@ const CREATOR_LIST_SORT_FIELD_MAP: Record< /** * Map a public sort option into an internal Prisma orderBy object. * Throws for unsupported values so invalid sort input is never passed through silently. + * Handles null values deterministically by sorting nulls last. */ export function mapCreatorListSort( sort: string, @@ -40,6 +41,6 @@ export function mapCreatorListSort( } return { - [field]: order, + [field]: { sort: order, nulls: 'last' }, } as Prisma.CreatorProfileOrderByWithRelationInput; } From 0c83f618427e7bf9f65d2696b17d245fea9609ab Mon Sep 17 00:00:00 2001 From: buinntalen Date: Sat, 25 Apr 2026 17:41:59 +0000 Subject: [PATCH 2/4] feat(cache): add stale-if-error cache hint for public creator reads Support stale-if-error behavior for cached public reads by adding staleIfError option to cache control middleware. Define 24-hour max stale window for creator list, stats, and profile endpoints to maintain service availability during origin failures while keeping response metadata explicit. Refs #147 --- src/constants/creator-public-cache.constants.ts | 3 +++ src/middlewares/cache-control.middleware.ts | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/src/constants/creator-public-cache.constants.ts b/src/constants/creator-public-cache.constants.ts index 1c52e3e..537d0b5 100644 --- a/src/constants/creator-public-cache.constants.ts +++ b/src/constants/creator-public-cache.constants.ts @@ -22,14 +22,17 @@ export const CREATOR_PUBLIC_ROUTE_CACHE_PRESETS = { [CREATOR_PUBLIC_ROUTE_NAMES.LIST]: { maxAge: publicReadSeconds, type: 'public' as const, + staleIfError: 86400, }, [CREATOR_PUBLIC_ROUTE_NAMES.GET_STATS]: { maxAge: publicReadSeconds, type: 'public' as const, + staleIfError: 86400, }, [CREATOR_PUBLIC_ROUTE_NAMES.GET_PROFILE]: { maxAge: publicReadSeconds, type: 'public' as const, + staleIfError: 86400, }, } as const; diff --git a/src/middlewares/cache-control.middleware.ts b/src/middlewares/cache-control.middleware.ts index 350ac57..2e3b2ce 100644 --- a/src/middlewares/cache-control.middleware.ts +++ b/src/middlewares/cache-control.middleware.ts @@ -30,6 +30,11 @@ export interface CacheControlOptions { * Default: false */ noStore?: boolean; + /** + * Max stale window in seconds. When set, allows serving stale content + * if the origin is unreachable. Default: undefined (disabled) + */ + staleIfError?: number; } /** @@ -55,6 +60,7 @@ export function cacheControl(options: CacheControlOptions = {}) { mustRevalidate = false, noCache = false, noStore = false, + staleIfError, } = options; return (req: Request, res: Response, next: NextFunction): void => { @@ -77,6 +83,9 @@ export function cacheControl(options: CacheControlOptions = {}) { if (mustRevalidate) { directives.push('must-revalidate'); } + if (staleIfError !== undefined) { + directives.push(`stale-if-error=${staleIfError}`); + } } res.setHeader('Cache-Control', directives.join(', ')); From a709ef1e4ea971bbc5dee6adb0ad1500d6093855 Mon Sep 17 00:00:00 2001 From: buinntalen Date: Sat, 25 Apr 2026 17:42:06 +0000 Subject: [PATCH 3/4] feat(audit): add audit event for admin moderation metadata edits Emit structured audit events for admin metadata changes to CreatorProfile, including actor, target, timestamp fields. Create audit events table to persist action history with granular change tracking. Add PATCH endpoint for updating creator metadata with x-admin-id header authentication and change tracking to avoid sensitive payload leakage. Refs #148 --- prisma/schema/audit.prisma | 9 +++ src/modules/admin/admin.controllers.ts | 81 ++++++++++++++++++++++++++ src/modules/admin/admin.routes.ts | 8 +++ src/modules/index.ts | 2 + src/utils/audit.utils.ts | 30 ++++++++++ 5 files changed, 130 insertions(+) create mode 100644 prisma/schema/audit.prisma create mode 100644 src/modules/admin/admin.controllers.ts create mode 100644 src/modules/admin/admin.routes.ts create mode 100644 src/utils/audit.utils.ts diff --git a/prisma/schema/audit.prisma b/prisma/schema/audit.prisma new file mode 100644 index 0000000..e0291b8 --- /dev/null +++ b/prisma/schema/audit.prisma @@ -0,0 +1,9 @@ +model AuditEvent { + id String @id @default(cuid()) + actor String + action String + target String + targetId String + metadata Json? + createdAt DateTime @default(now()) +} diff --git a/src/modules/admin/admin.controllers.ts b/src/modules/admin/admin.controllers.ts new file mode 100644 index 0000000..dbe2db2 --- /dev/null +++ b/src/modules/admin/admin.controllers.ts @@ -0,0 +1,81 @@ +import { AsyncController } from '../../types/auth.types'; +import { sendSuccess, sendValidationError, sendNotFound } from '../../utils/api-response.utils'; +import { prisma } from '../../utils/prisma.utils'; +import { emitAuditEvent } from '../../utils/audit.utils'; +import { z } from 'zod'; + +const UpdateCreatorMetadataSchema = z.object({ + isVerified: z.boolean().optional(), +}); + +type UpdateCreatorMetadataInput = z.infer; + +export const httpUpdateCreatorMetadata: AsyncController = async (req, res, next) => { + try { + const { id } = req.params as { id: string }; + const adminIdHeader = req.headers['x-admin-id']; + const actorId = + typeof adminIdHeader === 'string' + ? adminIdHeader + : Array.isArray(adminIdHeader) + ? adminIdHeader[0] + : undefined; + + if (!id || !actorId) { + return sendValidationError(res, 'Missing required parameters', [ + { field: 'id', message: 'Creator ID is required' }, + { field: 'x-admin-id', message: 'Admin ID header is required' }, + ]); + } + + const parsed = UpdateCreatorMetadataSchema.safeParse(req.body); + if (!parsed.success) { + return sendValidationError(res, 'Invalid request body', [ + { field: 'body', message: 'Invalid metadata update' }, + ]); + } + + const updates = parsed.data as UpdateCreatorMetadataInput; + + const creator = await prisma.creatorProfile.findUnique({ + where: { id }, + }); + + if (!creator) { + return sendNotFound(res, 'Creator'); + } + + const previousValues = { + isVerified: creator.isVerified, + }; + + const updated = await prisma.creatorProfile.update({ + where: { id }, + data: updates, + }); + + const changes: Record = {}; + Object.entries(updates).forEach(([key, value]) => { + if (value !== previousValues[key as keyof typeof previousValues]) { + changes[key] = { + before: previousValues[key as keyof typeof previousValues], + after: value, + }; + } + }); + + if (Object.keys(changes).length > 0) { + await emitAuditEvent({ + actor: actorId, + action: 'update_creator_metadata', + target: 'CreatorProfile', + targetId: id, + metadata: changes, + }); + } + + sendSuccess(res, updated); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/admin/admin.routes.ts b/src/modules/admin/admin.routes.ts new file mode 100644 index 0000000..de3d5b6 --- /dev/null +++ b/src/modules/admin/admin.routes.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import { httpUpdateCreatorMetadata } from './admin.controllers'; + +const adminRouter = Router(); + +adminRouter.patch('/creators/:id/metadata', httpUpdateCreatorMetadata); + +export default adminRouter; diff --git a/src/modules/index.ts b/src/modules/index.ts index e6fa9d1..f4e2e35 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -4,6 +4,7 @@ import healthRouter from './health/health.routes'; import configRouter from './config/config.routes'; import creatorsRouter from './creators/creators.routes'; import metricsRouter from './metrics/metrics.routes'; +import adminRouter from './admin/admin.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -13,5 +14,6 @@ router.use('/auth', authRouter); router.use('/config', configRouter); router.use(CREATORS_BASE, creatorsRouter); router.use('/metrics', metricsRouter); +router.use('/admin', adminRouter); export default router; diff --git a/src/utils/audit.utils.ts b/src/utils/audit.utils.ts new file mode 100644 index 0000000..ef4b023 --- /dev/null +++ b/src/utils/audit.utils.ts @@ -0,0 +1,30 @@ +import { prisma } from './prisma.utils'; + +export interface AuditEventPayload { + actor: string; + action: string; + target: string; + targetId: string; + metadata?: Record; +} + +export async function emitAuditEvent(payload: AuditEventPayload): Promise { + try { + const data: Record = { + actor: payload.actor, + action: payload.action, + target: payload.target, + targetId: payload.targetId, + }; + + if (payload.metadata) { + data.metadata = payload.metadata as Record; + } + + await prisma.auditEvent.create({ + data: data as Parameters[0]['data'], + }); + } catch (error) { + console.error('Failed to emit audit event:', error); + } +} From 2d09f69ac434de7af56d4ec0b36eb5732d8a3c40 Mon Sep 17 00:00:00 2001 From: buinntalen Date: Sat, 25 Apr 2026 17:42:27 +0000 Subject: [PATCH 4/4] docs: document API version header middleware configuration Add API_VERSION, ENABLE_API_VERSION_HEADER, and related configuration variables to .env.example to document the contract for the API version response header middleware. Keep version source centralized in config as designed. Refs #144 --- .env.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.env.example b/.env.example index 6c61d43..048949f 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,9 @@ CLOUDINARY_API_SECRET= PAYSTACK_SECRET_KEY= PAYSTACK_PUBLIC_KEY= + +# API Configuration +API_VERSION=1.0.0 +ENABLE_API_VERSION_HEADER=true +ENABLE_RESPONSE_TIMING=true +ENABLE_REQUEST_LOGGING=true