Skip to content

Commit 1960078

Browse files
authored
Merge pull request #651 from Jay-Peter-Egemasi/codex/routes-b-request-notifications-dashboard-audit
feat: add routes-b request ids and endpoints
2 parents 5bdbccc + 916c7ec commit 1960078

90 files changed

Lines changed: 1400 additions & 707 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/api/routes-b/_health/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { withRequestId } from '../_lib/with-request-id'
12
import { NextResponse } from 'next/server'
23
import { prisma } from '@/lib/db'
34

@@ -13,7 +14,7 @@ async function checkDbWithTimeout() {
1314
await Promise.race([dbCheck, timeout])
1415
}
1516

16-
export async function GET() {
17+
async function GETHandler() {
1718
const startedAt = Date.now()
1819
const responseTimeout = new Promise<never>((_, reject) => {
1920
setTimeout(() => reject(new Error('HEALTH_TIMEOUT')), HARD_TIMEOUT_MS)
@@ -47,3 +48,4 @@ export async function GET() {
4748
}
4849
}
4950

51+
export const GET = withRequestId(GETHandler)

app/api/routes-b/_lib/amounts.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function normalizeCurrencyAmount(value: unknown): number {
2+
return Number(value ?? 0)
3+
}

app/api/routes-b/_lib/flags.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ const defaultFlags: Record<string, FlagValue> = {
2424
'webhook-event-filtering': 'off',
2525
};
2626

27+
export const ENABLE_CONTACTS_SOFT_DELETE = process.env.ENABLE_CONTACTS_SOFT_DELETE === 'true' ||
28+
process.env.FLAG_CONTACTS_SOFT_DELETE === 'on'
29+
2730
function parseFlagValue(envValue: string | undefined): FlagValue {
2831
if (!envValue) return 'off';
2932

@@ -58,9 +61,9 @@ export function isEnabled(flagName: string, context: FlagContext = {}): boolean
5861
// Check cache first
5962
const cacheKey = `${flagName}:${context.userId || 'no-user'}`;
6063
if (flagCache.has(cacheKey)) {
61-
return flagCache.get(cacheKey) === 'on' ||
62-
(Array.isArray(flagCache.get(cacheKey)) && context.userId &&
63-
(flagCache.get(cacheKey) as string[]).includes(context.userId));
64+
const cached = flagCache.get(cacheKey)
65+
return cached === 'on' ||
66+
Boolean(Array.isArray(cached) && context.userId && cached.includes(context.userId))
6467
}
6568

6669
// Get flag value from env or defaults
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { deleteCacheValue, getCacheValue, setCacheValue } from './cache'
2+
3+
const UNREAD_COUNT_TTL_MS = 10_000
4+
5+
function unreadCountCacheKey(userId: string) {
6+
return `notifications:unread-count:${userId}`
7+
}
8+
9+
export function getCachedUnreadCount(userId: string): number | null {
10+
return getCacheValue<number>(unreadCountCacheKey(userId))
11+
}
12+
13+
export function setCachedUnreadCount(userId: string, count: number) {
14+
setCacheValue(unreadCountCacheKey(userId), count, UNREAD_COUNT_TTL_MS)
15+
}
16+
17+
export function bustUnreadCountCache(userId: string) {
18+
deleteCacheValue(unreadCountCacheKey(userId))
19+
}

app/api/routes-b/_lib/presigned-upload.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,20 @@ const CLOUDINARY_CLOUD_NAME = process.env.CLOUDINARY_CLOUD_NAME
1919
const CLOUDINARY_API_KEY = process.env.CLOUDINARY_API_KEY
2020
const CLOUDINARY_API_SECRET = process.env.CLOUDINARY_API_SECRET
2121

22-
if (!CLOUDINARY_CLOUD_NAME || !CLOUDINARY_API_KEY || !CLOUDINARY_API_SECRET) {
23-
throw new Error('Missing Cloudinary configuration')
22+
function requireCloudinaryConfig() {
23+
if (!CLOUDINARY_CLOUD_NAME || !CLOUDINARY_API_KEY || !CLOUDINARY_API_SECRET) {
24+
throw new Error('Missing Cloudinary configuration')
25+
}
26+
27+
return {
28+
cloudName: CLOUDINARY_CLOUD_NAME,
29+
apiKey: CLOUDINARY_API_KEY,
30+
apiSecret: CLOUDINARY_API_SECRET,
31+
}
2432
}
2533

2634
export function generatePresignedUpload(userId: string): PresignedUploadResponse {
35+
const config = requireCloudinaryConfig()
2736
const timestamp = Math.round(Date.now() / 1000)
2837
const publicId = `avatars/${userId}/${timestamp}`
2938
const folder = 'avatars'
@@ -45,15 +54,15 @@ export function generatePresignedUpload(userId: string): PresignedUploadResponse
4554
.join('&')
4655

4756
const signature = createHash('sha1')
48-
.update(signatureString + CLOUDINARY_API_SECRET)
57+
.update(signatureString + config.apiSecret)
4958
.digest('hex')
5059

5160
const expiresAt = new Date(Date.now() + 60 * 1000) // 60 seconds from now
5261

5362
return {
54-
url: `https://api.cloudinary.com/v1_1/${CLOUDINARY_CLOUD_NAME}/auto/upload`,
63+
url: `https://api.cloudinary.com/v1_1/${config.cloudName}/auto/upload`,
5564
fields: {
56-
api_key: CLOUDINARY_API_KEY,
65+
api_key: config.apiKey,
5766
timestamp: timestamp.toString(),
5867
public_id: publicId,
5968
folder,
@@ -105,7 +114,7 @@ export async function validateUploadedFile(key: string, buffer: ArrayBuffer): Pr
105114
}
106115

107116
export function generateCloudinaryUrl(key: string): string {
108-
return `https://res.cloudinary.com/${CLOUDINARY_CLOUD_NAME}/image/upload/${key}.jpg`
117+
return `https://res.cloudinary.com/${CLOUDINARY_CLOUD_NAME || 'demo'}/image/upload/${key}.jpg`
109118
}
110119

111120
export function isExpiredKey(expiresAt: string): boolean {

app/api/routes-b/_lib/rate-limit.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,14 @@ export function checkRateLimit(
3838
export function resetRateLimitBuckets() {
3939
buckets.clear()
4040
}
41+
42+
export async function rateLimit(key: string, limit: number, windowMs: number) {
43+
const result = checkRateLimit(key, { limit, windowMs })
44+
45+
return result.allowed
46+
? { allowed: true as const }
47+
: {
48+
allowed: false as const,
49+
resetTime: new Date(Date.now() + result.retryAfter * 1000).toISOString(),
50+
}
51+
}

app/api/routes-b/_lib/swr-cache.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const swrStore = globalThis.__routesBSwrStore ??= new Map()
2+
3+
function swrGet(key) {
4+
const entry = swrStore.get(key)
5+
if (!entry) return null
6+
if (Date.now() > entry.staleUntil) {
7+
swrStore.delete(key)
8+
return null
9+
}
10+
return entry
11+
}
12+
13+
function swrSet(key, value, freshMs, staleMs) {
14+
const now = Date.now()
15+
swrStore.set(key, {
16+
value,
17+
fetchedAt: now,
18+
freshUntil: now + freshMs,
19+
staleUntil: now + staleMs,
20+
})
21+
}
22+
23+
function swrDelete(key) {
24+
swrStore.delete(key)
25+
}
26+
27+
function swrClear() {
28+
swrStore.clear()
29+
}
30+
31+
function swrIsFresh(entry) {
32+
return Date.now() < entry.freshUntil
33+
}
34+
35+
function swrIsStale(entry) {
36+
const now = Date.now()
37+
return now >= entry.freshUntil && now < entry.staleUntil
38+
}
39+
40+
module.exports = {
41+
swrGet,
42+
swrSet,
43+
swrDelete,
44+
swrClear,
45+
swrIsFresh,
46+
swrIsStale,
47+
}

app/api/routes-b/_lib/swr-cache.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ export type SwrEntry<T> = {
55
staleUntil: number
66
}
77

8-
const swrStore = new Map<string, SwrEntry<unknown>>()
8+
const swrStore = ((globalThis as typeof globalThis & {
9+
__routesBSwrStore?: Map<string, SwrEntry<unknown>>
10+
}).__routesBSwrStore ??= new Map<string, SwrEntry<unknown>>())
911

1012
export function swrGet<T>(key: string): SwrEntry<T> | null {
1113
const entry = swrStore.get(key)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { AsyncLocalStorage } from 'async_hooks'
2+
import { randomBytes } from 'crypto'
3+
import { NextResponse } from 'next/server'
4+
import { logger } from '@/lib/logger'
5+
6+
type RequestContext = {
7+
requestId: string
8+
}
9+
10+
type RouteHandler = (...args: any[]) => unknown | Promise<unknown>
11+
12+
const requestContext = new AsyncLocalStorage<RequestContext>()
13+
const LOGGER_PATCHED = Symbol.for('routes-b.logger.request-id-patched')
14+
const UUID_PATTERN =
15+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
16+
17+
function generateUuidV7(): string {
18+
const bytes = randomBytes(16)
19+
const timestamp = Date.now()
20+
21+
bytes[0] = Math.floor(timestamp / 0x10000000000) & 0xff
22+
bytes[1] = Math.floor(timestamp / 0x100000000) & 0xff
23+
bytes[2] = Math.floor(timestamp / 0x1000000) & 0xff
24+
bytes[3] = Math.floor(timestamp / 0x10000) & 0xff
25+
bytes[4] = Math.floor(timestamp / 0x100) & 0xff
26+
bytes[5] = timestamp & 0xff
27+
bytes[6] = (bytes[6] & 0x0f) | 0x70
28+
bytes[8] = (bytes[8] & 0x3f) | 0x80
29+
30+
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
31+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
32+
}
33+
34+
function isUuid(value: string | null): value is string {
35+
return Boolean(value && UUID_PATTERN.test(value))
36+
}
37+
38+
function getRequestHeader(args: any[], name: string): string | null {
39+
const request = args[0]
40+
return typeof request?.headers?.get === 'function' ? request.headers.get(name) : null
41+
}
42+
43+
function resolveRequestId(args: any[]) {
44+
const clientRequestId = getRequestHeader(args, 'x-request-id')
45+
return isUuid(clientRequestId) ? clientRequestId : generateUuidV7()
46+
}
47+
48+
export function getRequestId(): string | null {
49+
return requestContext.getStore()?.requestId ?? null
50+
}
51+
52+
function patchLogger() {
53+
const target = logger as any
54+
if (target[LOGGER_PATCHED]) return
55+
56+
for (const method of ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const) {
57+
const original = target[method]
58+
if (typeof original !== 'function') continue
59+
if (original.mock || original._isMockFunction) continue
60+
61+
target[method] = function requestIdLoggerMethod(this: unknown, ...args: any[]) {
62+
const requestId = getRequestId()
63+
if (requestId) {
64+
const first = args[0]
65+
if (first && typeof first === 'object' && !Array.isArray(first) && !(first instanceof Error)) {
66+
args[0] = { requestId, ...first }
67+
} else {
68+
args.unshift({ requestId })
69+
}
70+
}
71+
72+
return original.apply(this, args)
73+
}
74+
}
75+
76+
target[LOGGER_PATCHED] = true
77+
}
78+
79+
function responseWithRequestId(response: unknown, requestId: string): Response {
80+
const routeResponse = response instanceof Response
81+
? response
82+
: new Response(null, { status: 204 })
83+
84+
routeResponse.headers.set('X-Request-Id', requestId)
85+
return routeResponse
86+
}
87+
88+
patchLogger()
89+
90+
export function withRequestId<T extends RouteHandler>(handler: T): (...args: any[]) => Promise<Response> {
91+
return (async (...args: any[]) => {
92+
const requestId = resolveRequestId(args)
93+
94+
try {
95+
const response = await requestContext.run({ requestId }, () => handler(...args))
96+
return responseWithRequestId(response, requestId)
97+
} catch (error) {
98+
logger.error({ err: error }, 'Routes B unhandled route error')
99+
return responseWithRequestId(
100+
NextResponse.json({ error: 'Internal server error' }, { status: 500 }),
101+
requestId,
102+
)
103+
}
104+
})
105+
}

app/api/routes-b/_openapi/route.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { withRequestId } from '../_lib/with-request-id'
12
import { NextRequest, NextResponse } from 'next/server'
23
import { generateOpenAPIDocument } from '../_lib/openapi'
34

4-
export async function GET(request: NextRequest) {
5+
async function GETHandler(request: NextRequest) {
56
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ||
67
`https://${request.headers.get('host')}` ||
78
'http://localhost:3000'
@@ -14,4 +15,6 @@ export async function GET(request: NextRequest) {
1415
'Cache-Control': 'no-cache'
1516
}
1617
})
17-
}
18+
}
19+
20+
export const GET = withRequestId(GETHandler)

0 commit comments

Comments
 (0)