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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions prisma/schema/audit.prisma
Original file line number Diff line number Diff line change
@@ -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())
}
3 changes: 3 additions & 0 deletions src/constants/creator-public-cache.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
9 changes: 9 additions & 0 deletions src/middlewares/cache-control.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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 => {
Expand All @@ -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(', '));
Expand Down
81 changes: 81 additions & 0 deletions src/modules/admin/admin.controllers.ts
Original file line number Diff line number Diff line change
@@ -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<typeof UpdateCreatorMetadataSchema>;

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<string, unknown> = {};
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);
}
};
8 changes: 8 additions & 0 deletions src/modules/admin/admin.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 2 additions & 1 deletion src/modules/creators/creators.sort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -40,6 +41,6 @@ export function mapCreatorListSort(
}

return {
[field]: order,
[field]: { sort: order, nulls: 'last' },
} as Prisma.CreatorProfileOrderByWithRelationInput;
}
2 changes: 2 additions & 0 deletions src/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
30 changes: 30 additions & 0 deletions src/utils/audit.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { prisma } from './prisma.utils';

export interface AuditEventPayload {
actor: string;
action: string;
target: string;
targetId: string;
metadata?: Record<string, unknown>;
}

export async function emitAuditEvent(payload: AuditEventPayload): Promise<void> {
try {
const data: Record<string, unknown> = {
actor: payload.actor,
action: payload.action,
target: payload.target,
targetId: payload.targetId,
};

if (payload.metadata) {
data.metadata = payload.metadata as Record<string, unknown>;
}

await prisma.auditEvent.create({
data: data as Parameters<typeof prisma.auditEvent.create>[0]['data'],
});
} catch (error) {
console.error('Failed to emit audit event:', error);
}
}
Loading