Skip to content
Closed
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
8 changes: 8 additions & 0 deletions app/api/routes-b/_lib/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export function deleteCacheValue(key: string): void {
store.delete(key)
}

export function deleteCachePrefix(prefix: string): void {
for (const key of store.keys()) {
if (key.startsWith(prefix)) {
store.delete(key)
}
}
}

export function clearCache(): void {
store.clear()
}
Expand Down
16 changes: 14 additions & 2 deletions app/api/routes-b/_lib/file-signature.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const
const ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const

export function getMaxFileSize(): number {
return 2 * 1024 * 1024
Expand Down Expand Up @@ -37,6 +37,16 @@ export function sniffMimeType(buffer: ArrayBuffer): (typeof ALLOWED_MIME_TYPES)[
return 'image/webp'
}

if (
bytes.length >= 4 &&
bytes[0] === 0x47 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x38
) {
return 'image/gif'
}

return null
}

Expand All @@ -52,6 +62,7 @@ export function stripExifMetadata(buffer: ArrayBuffer, mimeType: string): ArrayB

const out: number[] = [0xff, 0xd8]
let i = 2
let success = false

while (i < src.length) {
if (src[i] !== 0xff) {
Expand All @@ -65,6 +76,7 @@ export function stripExifMetadata(buffer: ArrayBuffer, mimeType: string): ArrayB

if (marker === 0xd9 || marker === 0xda) {
for (let j = i; j < src.length; j += 1) out.push(src[j])
success = true
break
}

Expand All @@ -80,5 +92,5 @@ export function stripExifMetadata(buffer: ArrayBuffer, mimeType: string): ArrayB
i += 2 + length
}

return new Uint8Array(out).buffer
return success ? new Uint8Array(out).buffer : buffer
}
33 changes: 18 additions & 15 deletions app/api/routes-b/_lib/presigned-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@ export interface UploadValidation {
size?: number
}

const CLOUDINARY_CLOUD_NAME = process.env.CLOUDINARY_CLOUD_NAME
const CLOUDINARY_API_KEY = process.env.CLOUDINARY_API_KEY
const CLOUDINARY_API_SECRET = process.env.CLOUDINARY_API_SECRET

if (!CLOUDINARY_CLOUD_NAME || !CLOUDINARY_API_KEY || !CLOUDINARY_API_SECRET) {
throw new Error('Missing Cloudinary configuration')
}
export { getMaxFileSize } from './file-signature'

export function generatePresignedUpload(userId: string): PresignedUploadResponse {
const cloudinaryCloudName = process.env.CLOUDINARY_CLOUD_NAME
const cloudinaryApiKey = process.env.CLOUDINARY_API_KEY
const cloudinaryApiSecret = process.env.CLOUDINARY_API_SECRET

if (!cloudinaryCloudName || !cloudinaryApiKey || !cloudinaryApiSecret) {
throw new Error('Missing Cloudinary configuration')
}

const timestamp = Math.round(Date.now() / 1000)
const publicId = `avatars/${userId}/${timestamp}`
const folder = 'avatars'
Expand All @@ -35,7 +37,7 @@ export function generatePresignedUpload(userId: string): PresignedUploadResponse
folder,
resource_type: 'auto',
max_file_size: getMaxFileSize(),
allowed_formats: 'jpg,jpeg,png,webp'
allowed_formats: 'jpg,jpeg,png,gif,webp'
}

// Create signature string
Expand All @@ -45,22 +47,22 @@ export function generatePresignedUpload(userId: string): PresignedUploadResponse
.join('&')

const signature = createHash('sha1')
.update(signatureString + CLOUDINARY_API_SECRET)
.update(signatureString + cloudinaryApiSecret)
.digest('hex')

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

return {
url: `https://api.cloudinary.com/v1_1/${CLOUDINARY_CLOUD_NAME}/auto/upload`,
url: `https://api.cloudinary.com/v1_1/${cloudinaryCloudName}/auto/upload`,
fields: {
api_key: CLOUDINARY_API_KEY,
api_key: cloudinaryApiKey,
timestamp: timestamp.toString(),
public_id: publicId,
folder,
signature,
resource_type: 'auto',
max_file_size: getMaxFileSize().toString(),
allowed_formats: 'jpg,jpeg,png,webp'
allowed_formats: 'jpg,jpeg,png,gif,webp'
},
key: publicId,
expiresAt: expiresAt.toISOString()
Expand All @@ -72,7 +74,7 @@ export async function validateUploadedFile(key: string, buffer: ArrayBuffer): Pr
if (buffer.byteLength > getMaxFileSize()) {
return {
valid: false,
error: 'File size exceeds 2MiB limit',
error: `File size exceeds ${getMaxFileSize() === 2 * 1024 * 1024 ? '2MiB' : '5MB'} limit`,
size: buffer.byteLength
}
}
Expand All @@ -82,7 +84,7 @@ export async function validateUploadedFile(key: string, buffer: ArrayBuffer): Pr
if (!mimeType) {
return {
valid: false,
error: 'Invalid file type. Only JPEG, PNG, and WebP are allowed'
error: 'Invalid file type. Only JPEG, PNG, GIF, and WebP are allowed'
}
}

Expand All @@ -105,7 +107,8 @@ export async function validateUploadedFile(key: string, buffer: ArrayBuffer): Pr
}

export function generateCloudinaryUrl(key: string): string {
return `https://res.cloudinary.com/${CLOUDINARY_CLOUD_NAME}/image/upload/${key}.jpg`
const cloudinaryCloudName = process.env.CLOUDINARY_CLOUD_NAME
return `https://res.cloudinary.com/${cloudinaryCloudName}/image/upload/${key}.jpg`
}

export function isExpiredKey(expiresAt: string): boolean {
Expand Down
2 changes: 1 addition & 1 deletion app/api/routes-b/_lib/redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function redactProfile(data: any, options: RedactionOptions = {}): any {
name: 'full',
email: isAdmin ? 'full' : 'masked',
phone: isAdmin ? 'full' : 'masked',
address: isAdmin ? 'full' : 'hidden',
address: isAdmin ? 'full' : 'masked',
governmentId: 'hidden', // Always hide government ID
avatarUrl: 'full',
taxPercentage: 'full',
Expand Down
3 changes: 2 additions & 1 deletion app/api/routes-b/analytics/top-months/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getCacheValue,
setCacheValue,
deleteCacheValue,
deleteCachePrefix,
} from "../../_lib/cache";
import { onInvoicePaid } from "../../_lib/events";
import { isValidTimezone } from "../../_lib/date-range";
Expand All @@ -16,7 +17,7 @@ function ensureTopMonthsCacheInvalidationHook() {
if (topMonthsEventHooked) return;
topMonthsEventHooked = true;
onInvoicePaid(({ userId }) => {
deleteCacheValue(`routes-b:analytics:top-months:${userId}`);
deleteCachePrefix(`routes-b:analytics:top-months:${userId}`);
});
}

Expand Down
19 changes: 13 additions & 6 deletions app/api/routes-b/analytics/withdrawals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,22 @@ export async function GET(request: NextRequest) {
toExclusive,
);

const mappedBuckets = rows.map((row) => ({
bucket: row.bucket.toISOString(),
count: Number(row.count),
totalAmount: Number(row.total_amount ?? 0),
avgAmount: Number(row.avg_amount ?? 0),
}));

return NextResponse.json({
groupBy,
tz,
buckets: rows.map((row) => ({
bucket: row.bucket.toISOString(),
count: Number(row.count),
totalAmount: Number(row.total_amount ?? 0),
avgAmount: Number(row.avg_amount ?? 0),
})),
buckets: mappedBuckets,
withdrawals: {
groupBy,
tz,
buckets: mappedBuckets,
}
});
} catch (error) {
logger.error({ err: error }, "Routes B analytics withdrawals GET error");
Expand Down
2 changes: 1 addition & 1 deletion app/api/routes-b/profile/avatar/finalize/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { verifyAuthToken } from '@/lib/auth'
import { generateCloudinaryUrl, isExpiredKey } from '../../_lib/presigned-upload'
import { generateCloudinaryUrl, isExpiredKey } from '../../../_lib/presigned-upload'
import { getMaxFileSize, isAllowedMimeType, sniffMimeType, stripExifMetadata } from '../../../_lib/file-signature'
import { registerRoute } from '../../../_lib/openapi'
import { z } from 'zod'
Expand Down
2 changes: 1 addition & 1 deletion app/api/routes-b/profile/tests/presigned-upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe('Presigned Upload', () => {
const result = await validateUploadedFile('test-key', oversizedBuffer)

expect(result.valid).toBe(false)
expect(result.error).toBe('File size exceeds 5MB limit')
expect(result.error).toBe('File size exceeds 2MiB limit')
expect(result.size).toBe(getMaxFileSize() + 1)
})

Expand Down
13 changes: 11 additions & 2 deletions app/api/routes-b/search/__tests__/search-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ vi.mock('@/lib/auth', () => ({
vi.mock('@/lib/db', () => ({
prisma: {
user: { findUnique: vi.fn() },
invoice: { findMany: vi.fn() },
bankAccount: { findMany: vi.fn() },
invoice: { findMany: vi.fn(), count: vi.fn().mockResolvedValue(0), groupBy: vi.fn().mockResolvedValue([]) },
bankAccount: { findMany: vi.fn(), count: vi.fn().mockResolvedValue(0) },
contact: { findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(0) },
tag: { findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(0) },
},
}))

Expand Down Expand Up @@ -39,6 +41,13 @@ describe('GET /api/routes-b/search validation', () => {
mockedUserFindUnique.mockResolvedValue({ id: 'user-1' } as never)
mockedInvoiceFindMany.mockResolvedValue([] as never)
mockedBankFindMany.mockResolvedValue([] as never)
vi.mocked(prisma.contact.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.tag.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.invoice.count).mockResolvedValue(0 as never)
vi.mocked(prisma.bankAccount.count).mockResolvedValue(0 as never)
vi.mocked(prisma.contact.count).mockResolvedValue(0 as never)
vi.mocked(prisma.tag.count).mockResolvedValue(0 as never)
vi.mocked(prisma.invoice.groupBy).mockResolvedValue([] as never)
})

it('rejects an empty query without calling search tables', async () => {
Expand Down
2 changes: 1 addition & 1 deletion app/api/routes-b/tags/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ describe('POST /api/routes-b/tags', () => {

it('creates a tag when payload is valid', async () => {
mockedTagFindUnique.mockResolvedValue(null as never)
mockedTagCreate.mockResolvedValue(makeTag({ id: 'tag-new', name: 'Urgent' }) as never)
mockedTagCreate.mockResolvedValue(makeTag({ id: 'tag-new', name: 'Urgent', color: '#123456' }) as never)
const request = buildRequest('POST', 'http://localhost/api/routes-b/tags', {
token: 'token',
body: { name: 'Urgent', color: '#123456' },
Expand Down
1 change: 1 addition & 0 deletions app/api/routes-b/tests/timezone-analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ vi.mock('@/lib/db', () => ({
invoice: {
findMany: vi.fn().mockResolvedValue([]),
},
$queryRawUnsafe: vi.fn().mockResolvedValue([]),
},
}))

Expand Down
4 changes: 2 additions & 2 deletions app/api/routes-b/tests/wallet-swr.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { swrGet, swrSet, swrClear, swrIsFresh, swrIsStale, type SwrEntry } from '../_lib/swr-cache'
import { swrGet, swrSet, swrClear, swrIsFresh, swrIsStale, swrDelete, type SwrEntry } from '../_lib/swr-cache'

// ── swr-cache unit tests ──────────────────────────────────────────────────────

Expand Down Expand Up @@ -51,7 +51,6 @@ describe('swr-cache', () => {
})

it('swrDelete removes an entry', () => {
const { swrDelete } = require('../_lib/swr-cache')
swrSet('x', 1, 15_000, 60_000)
swrDelete('x')
expect(swrGet('x')).toBeNull()
Expand Down Expand Up @@ -161,6 +160,7 @@ describe('GET /wallet — SWR caching', () => {
// Still returns the stale cached wallet
const body = await res.json()
expect(body.wallet).toHaveProperty('stellarAddress', 'GADDR123')
await new Promise((r) => setImmediate(r))
})

it('returns null wallet gracefully when DB returns null', async () => {
Expand Down
64 changes: 32 additions & 32 deletions app/api/routes-b/wallet/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@ import { prisma } from '@/lib/db'
import { verifyAuthToken } from '@/lib/auth'
import { logger } from '@/lib/logger'
import { classifyWalletError } from '../_lib/wallet-errors'
import { swrGet, swrSet, swrIsFresh, swrIsStale } from '../_lib/swr-cache'

const FRESH_MS = 15_000 // 15 s: serve from cache, no upstream call
const STALE_MS = 60_000 // 60 s: serve stale + revalidate in background

type WalletPayload = {
id: string
stellarAddress: string
balance: number | null
createdAt: Date
} | null

async function fetchWalletBalance(address: string): Promise<number | null> {
const statusUrl = process.env.CHAIN_RPC_WALLET_BALANCE_URL
Expand Down Expand Up @@ -41,17 +52,18 @@ async function fetchWalletBalance(address: string): Promise<number | null> {
}

return parsed
import { swrGet, swrSet, swrIsFresh, swrIsStale } from '../_lib/swr-cache'

const FRESH_MS = 15_000 // 15 s: serve from cache, no upstream call
const STALE_MS = 60_000 // 60 s: serve stale + revalidate in background

type WalletPayload = { id: string; stellarAddress: string; createdAt: Date } | null
}

async function fetchWalletFromDb(userId: string): Promise<WalletPayload> {
async function getWalletWithBalance(userId: string): Promise<WalletPayload> {
const wallet = await prisma.wallet.findUnique({ where: { userId } })
if (!wallet) return null
return { id: wallet.id, stellarAddress: wallet.address, createdAt: wallet.createdAt }
const balance = await fetchWalletBalance(wallet.address)
return {
id: wallet.id,
stellarAddress: wallet.address,
balance,
createdAt: wallet.createdAt,
}
}

export async function GET(request: NextRequest) {
Expand Down Expand Up @@ -80,7 +92,7 @@ export async function GET(request: NextRequest) {
// Within the stale window — return the cached value and revalidate in background
setImmediate(async () => {
try {
const fresh = await fetchWalletFromDb(user.id)
const fresh = await getWalletWithBalance(user.id)
swrSet(cacheKey, fresh, FRESH_MS, STALE_MS)
} catch {
// Keep serving the last known value; do not evict
Expand All @@ -96,16 +108,18 @@ export async function GET(request: NextRequest) {
const startedAt = Date.now()
const attempt = 1
try {
const balance = await fetchWalletBalance(wallet.address)
return NextResponse.json({
wallet: {
id: wallet.id,
stellarAddress: wallet.address,
balance,
createdAt: wallet.createdAt,
},
})
const wallet = await getWalletWithBalance(user.id)
swrSet(cacheKey, wallet, FRESH_MS, STALE_MS)
return NextResponse.json({ wallet })
} catch (error) {
// If we still have a (now-expired) value fall back to it rather than hard-failing
const stale = swrGet<WalletPayload>(cacheKey)
if (stale) {
const headers = new Headers()
headers.set('X-Cache', 'STALE')
return NextResponse.json({ wallet: stale.value }, { headers })
}

const failure = classifyWalletError(error)
logger.error(
{
Expand All @@ -124,19 +138,5 @@ export async function GET(request: NextRequest) {
},
{ status: failure.status },
)
// Cache miss or beyond stale window — synchronous upstream fetch
try {
const wallet = await fetchWalletFromDb(user.id)
swrSet(cacheKey, wallet, FRESH_MS, STALE_MS)
return NextResponse.json({ wallet })
} catch (error) {
// If we still have a (now-expired) value fall back to it rather than hard-failing
const stale = swrGet<WalletPayload>(cacheKey)
if (stale) {
const headers = new Headers()
headers.set('X-Cache', 'STALE')
return NextResponse.json({ wallet: stale.value }, { headers })
}
return NextResponse.json({ error: 'Failed to fetch wallet' }, { status: 500 })
}
}
Loading