Skip to content

Commit ddda42e

Browse files
authored
Merge pull request #175 from buinntalen/feat/stellar-wave-fixes
fix: batch of stellar wave fixes
2 parents 18f1d97 + 2d09f69 commit ddda42e

9 files changed

Lines changed: 150 additions & 1 deletion

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,9 @@ CLOUDINARY_API_SECRET=
1919

2020
PAYSTACK_SECRET_KEY=
2121
PAYSTACK_PUBLIC_KEY=
22+
23+
# API Configuration
24+
API_VERSION=1.0.0
25+
ENABLE_API_VERSION_HEADER=true
26+
ENABLE_RESPONSE_TIMING=true
27+
ENABLE_REQUEST_LOGGING=true

prisma/schema/audit.prisma

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
model AuditEvent {
2+
id String @id @default(cuid())
3+
actor String
4+
action String
5+
target String
6+
targetId String
7+
metadata Json?
8+
createdAt DateTime @default(now())
9+
}

src/constants/creator-public-cache.constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,17 @@ export const CREATOR_PUBLIC_ROUTE_CACHE_PRESETS = {
2222
[CREATOR_PUBLIC_ROUTE_NAMES.LIST]: {
2323
maxAge: publicReadSeconds,
2424
type: 'public' as const,
25+
staleIfError: 86400,
2526
},
2627
[CREATOR_PUBLIC_ROUTE_NAMES.GET_STATS]: {
2728
maxAge: publicReadSeconds,
2829
type: 'public' as const,
30+
staleIfError: 86400,
2931
},
3032
[CREATOR_PUBLIC_ROUTE_NAMES.GET_PROFILE]: {
3133
maxAge: publicReadSeconds,
3234
type: 'public' as const,
35+
staleIfError: 86400,
3336
},
3437
} as const;
3538

src/middlewares/cache-control.middleware.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ export interface CacheControlOptions {
3030
* Default: false
3131
*/
3232
noStore?: boolean;
33+
/**
34+
* Max stale window in seconds. When set, allows serving stale content
35+
* if the origin is unreachable. Default: undefined (disabled)
36+
*/
37+
staleIfError?: number;
3338
}
3439

3540
/**
@@ -55,6 +60,7 @@ export function cacheControl(options: CacheControlOptions = {}) {
5560
mustRevalidate = false,
5661
noCache = false,
5762
noStore = false,
63+
staleIfError,
5864
} = options;
5965

6066
return (req: Request, res: Response, next: NextFunction): void => {
@@ -77,6 +83,9 @@ export function cacheControl(options: CacheControlOptions = {}) {
7783
if (mustRevalidate) {
7884
directives.push('must-revalidate');
7985
}
86+
if (staleIfError !== undefined) {
87+
directives.push(`stale-if-error=${staleIfError}`);
88+
}
8089
}
8190

8291
res.setHeader('Cache-Control', directives.join(', '));
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { AsyncController } from '../../types/auth.types';
2+
import { sendSuccess, sendValidationError, sendNotFound } from '../../utils/api-response.utils';
3+
import { prisma } from '../../utils/prisma.utils';
4+
import { emitAuditEvent } from '../../utils/audit.utils';
5+
import { z } from 'zod';
6+
7+
const UpdateCreatorMetadataSchema = z.object({
8+
isVerified: z.boolean().optional(),
9+
});
10+
11+
type UpdateCreatorMetadataInput = z.infer<typeof UpdateCreatorMetadataSchema>;
12+
13+
export const httpUpdateCreatorMetadata: AsyncController = async (req, res, next) => {
14+
try {
15+
const { id } = req.params as { id: string };
16+
const adminIdHeader = req.headers['x-admin-id'];
17+
const actorId =
18+
typeof adminIdHeader === 'string'
19+
? adminIdHeader
20+
: Array.isArray(adminIdHeader)
21+
? adminIdHeader[0]
22+
: undefined;
23+
24+
if (!id || !actorId) {
25+
return sendValidationError(res, 'Missing required parameters', [
26+
{ field: 'id', message: 'Creator ID is required' },
27+
{ field: 'x-admin-id', message: 'Admin ID header is required' },
28+
]);
29+
}
30+
31+
const parsed = UpdateCreatorMetadataSchema.safeParse(req.body);
32+
if (!parsed.success) {
33+
return sendValidationError(res, 'Invalid request body', [
34+
{ field: 'body', message: 'Invalid metadata update' },
35+
]);
36+
}
37+
38+
const updates = parsed.data as UpdateCreatorMetadataInput;
39+
40+
const creator = await prisma.creatorProfile.findUnique({
41+
where: { id },
42+
});
43+
44+
if (!creator) {
45+
return sendNotFound(res, 'Creator');
46+
}
47+
48+
const previousValues = {
49+
isVerified: creator.isVerified,
50+
};
51+
52+
const updated = await prisma.creatorProfile.update({
53+
where: { id },
54+
data: updates,
55+
});
56+
57+
const changes: Record<string, unknown> = {};
58+
Object.entries(updates).forEach(([key, value]) => {
59+
if (value !== previousValues[key as keyof typeof previousValues]) {
60+
changes[key] = {
61+
before: previousValues[key as keyof typeof previousValues],
62+
after: value,
63+
};
64+
}
65+
});
66+
67+
if (Object.keys(changes).length > 0) {
68+
await emitAuditEvent({
69+
actor: actorId,
70+
action: 'update_creator_metadata',
71+
target: 'CreatorProfile',
72+
targetId: id,
73+
metadata: changes,
74+
});
75+
}
76+
77+
sendSuccess(res, updated);
78+
} catch (error) {
79+
next(error);
80+
}
81+
};

src/modules/admin/admin.routes.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Router } from 'express';
2+
import { httpUpdateCreatorMetadata } from './admin.controllers';
3+
4+
const adminRouter = Router();
5+
6+
adminRouter.patch('/creators/:id/metadata', httpUpdateCreatorMetadata);
7+
8+
export default adminRouter;

src/modules/creators/creators.sort.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const CREATOR_LIST_SORT_FIELD_MAP: Record<
2828
/**
2929
* Map a public sort option into an internal Prisma orderBy object.
3030
* Throws for unsupported values so invalid sort input is never passed through silently.
31+
* Handles null values deterministically by sorting nulls last.
3132
*/
3233
export function mapCreatorListSort(
3334
sort: string,
@@ -40,6 +41,6 @@ export function mapCreatorListSort(
4041
}
4142

4243
return {
43-
[field]: order,
44+
[field]: { sort: order, nulls: 'last' },
4445
} as Prisma.CreatorProfileOrderByWithRelationInput;
4546
}

src/modules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import healthRouter from './health/health.routes';
44
import configRouter from './config/config.routes';
55
import creatorsRouter from './creators/creators.routes';
66
import metricsRouter from './metrics/metrics.routes';
7+
import adminRouter from './admin/admin.routes';
78
import { BASE as CREATORS_BASE } from '../constants/creator.constants';
89

910
const router = Router();
@@ -13,5 +14,6 @@ router.use('/auth', authRouter);
1314
router.use('/config', configRouter);
1415
router.use(CREATORS_BASE, creatorsRouter);
1516
router.use('/metrics', metricsRouter);
17+
router.use('/admin', adminRouter);
1618

1719
export default router;

src/utils/audit.utils.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { prisma } from './prisma.utils';
2+
3+
export interface AuditEventPayload {
4+
actor: string;
5+
action: string;
6+
target: string;
7+
targetId: string;
8+
metadata?: Record<string, unknown>;
9+
}
10+
11+
export async function emitAuditEvent(payload: AuditEventPayload): Promise<void> {
12+
try {
13+
const data: Record<string, unknown> = {
14+
actor: payload.actor,
15+
action: payload.action,
16+
target: payload.target,
17+
targetId: payload.targetId,
18+
};
19+
20+
if (payload.metadata) {
21+
data.metadata = payload.metadata as Record<string, unknown>;
22+
}
23+
24+
await prisma.auditEvent.create({
25+
data: data as Parameters<typeof prisma.auditEvent.create>[0]['data'],
26+
});
27+
} catch (error) {
28+
console.error('Failed to emit audit event:', error);
29+
}
30+
}

0 commit comments

Comments
 (0)