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
49 changes: 49 additions & 0 deletions app/api/routes-b/trash/[id]/restore/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { withRequestId } from '../../../../_lib/with-request-id'
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { verifyAuthToken } from '@/lib/auth'
import { logger } from '@/lib/logger'

async function POSTHandler(
request: NextRequest,
context: { params: Promise<{ id: string }> },
) {
const authToken = request.headers.get('authorization')?.replace('Bearer ', '')
const claims = await verifyAuthToken(authToken || '')
if (!claims) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const user = await prisma.user.findUnique({ where: { privyId: claims.userId } })
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}

const { id } = await context.params

const trashItem = await prisma.trashItem.findFirst({
where: { id, userId: user.id },
select: { id: true, resourceType: true, resourceId: true, deletedAt: true },
})
if (!trashItem) {
return NextResponse.json({ error: 'Trash item not found' }, { status: 404 })
}

await prisma.$transaction([
prisma.trashItem.delete({ where: { id } }),
prisma.invoice.updateMany({
where: { id: trashItem.resourceId, userId: user.id },
data: { deletedAt: null },
}),
])

logger.info({ userId: user.id, trashItemId: id, resourceType: trashItem.resourceType }, 'Item restored from trash')

return NextResponse.json({
restored: true,
resourceType: trashItem.resourceType,
resourceId: trashItem.resourceId,
})
}

export const POST = withRequestId(POSTHandler)
45 changes: 45 additions & 0 deletions app/api/routes-d/api-keys/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { verifyAuthToken } from '@/lib/auth'
import { logger } from '@/lib/logger'

export async function DELETE(
request: NextRequest,
context: { params: Promise<{ id: string }> },
) {
try {
const authToken = request.headers.get('authorization')?.replace('Bearer ', '')
if (!authToken) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

const claims = await verifyAuthToken(authToken)
if (!claims) return NextResponse.json({ error: 'Invalid token' }, { status: 401 })

const user = await prisma.user.findUnique({ where: { privyId: claims.userId } })
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 401 })

const { id } = await context.params

const apiKey = await prisma.apiKey.findFirst({
where: { id, userId: user.id },
select: { id: true, revoked: true },
})
if (!apiKey) {
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
}
if (apiKey.revoked) {
return NextResponse.json({ error: 'API key already revoked' }, { status: 409 })
}

await prisma.apiKey.update({
where: { id },
data: { revoked: true, revokedAt: new Date() },
})

logger.info({ userId: user.id, apiKeyId: id }, 'API key revoked')

return new NextResponse(null, { status: 204 })
} catch (error) {
logger.error({ err: error }, 'DELETE /api-keys/[id] error')
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
61 changes: 61 additions & 0 deletions app/api/routes-d/billing/usage/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { verifyAuthToken } from '@/lib/auth'
import { logger } from '@/lib/logger'

export async function GET(request: NextRequest) {
try {
const authToken = request.headers.get('authorization')?.replace('Bearer ', '')
if (!authToken) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

const claims = await verifyAuthToken(authToken)
if (!claims) return NextResponse.json({ error: 'Invalid token' }, { status: 401 })

const user = await prisma.user.findUnique({
where: { privyId: claims.userId },
include: { subscription: true },
})
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 401 })

const now = new Date()
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1)

const [invoiceCount, apiCallCount, storageBytes] = await Promise.all([
prisma.invoice.count({ where: { userId: user.id, createdAt: { gte: periodStart } } }),
prisma.apiUsageLog.count({ where: { userId: user.id, createdAt: { gte: periodStart } } }),
prisma.storageUsage.aggregate({
_sum: { bytes: true },
where: { userId: user.id },
}),
])

const plan = user.subscription?.plan ?? 'free'
const limits = getPlanLimits(plan)

return NextResponse.json({
period: { start: periodStart.toISOString(), end: now.toISOString() },
plan,
usage: {
invoices: { used: invoiceCount, limit: limits.invoices },
apiCalls: { used: apiCallCount, limit: limits.apiCalls },
storageMb: {
used: Math.round((storageBytes._sum.bytes ?? 0) / (1024 * 1024)),
limit: limits.storageMb,
},
},
})
} catch (error) {
logger.error({ err: error }, 'GET /billing/usage error')
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

function getPlanLimits(plan: string) {
const limits: Record<string, { invoices: number; apiCalls: number; storageMb: number }> = {
free: { invoices: 10, apiCalls: 1_000, storageMb: 100 },
starter: { invoices: 100, apiCalls: 10_000, storageMb: 1_024 },
pro: { invoices: 1_000, apiCalls: 100_000, storageMb: 10_240 },
enterprise: { invoices: -1, apiCalls: -1, storageMb: -1 },
}
return limits[plan] ?? limits.free
}
66 changes: 66 additions & 0 deletions app/api/routes-d/quiet-hours/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { verifyAuthToken } from '@/lib/auth'
import { logger } from '@/lib/logger'

const VALID_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/

export async function POST(request: NextRequest) {
try {
const authToken = request.headers.get('authorization')?.replace('Bearer ', '')
if (!authToken) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

const claims = await verifyAuthToken(authToken)
if (!claims) return NextResponse.json({ error: 'Invalid token' }, { status: 401 })

const user = await prisma.user.findUnique({ where: { privyId: claims.userId } })
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 401 })

let body: { enabled?: unknown; startTime?: unknown; endTime?: unknown; days?: unknown; timezone?: unknown }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}

const { enabled = true, startTime, endTime, days, timezone = 'UTC' } = body

if (typeof enabled !== 'boolean') {
return NextResponse.json({ error: 'enabled must be a boolean' }, { status: 422 })
}
if (enabled) {
if (typeof startTime !== 'string' || !TIME_RE.test(startTime)) {
return NextResponse.json({ error: 'startTime must be HH:MM' }, { status: 422 })
}
if (typeof endTime !== 'string' || !TIME_RE.test(endTime)) {
return NextResponse.json({ error: 'endTime must be HH:MM' }, { status: 422 })
}
if (Array.isArray(days)) {
const bad = (days as unknown[]).filter((d) => !VALID_DAYS.includes(d as string))
if (bad.length > 0) {
return NextResponse.json({ error: `Invalid days: ${bad.join(', ')}` }, { status: 422 })
}
}
}

const preference = await prisma.notificationPreference.upsert({
where: { userId: user.id },
create: {
userId: user.id,
quietHours: { enabled, startTime, endTime, days: days ?? VALID_DAYS, timezone },
},
update: {
quietHours: { enabled, startTime, endTime, days: days ?? VALID_DAYS, timezone },
},
select: { id: true, quietHours: true, updatedAt: true },
})

logger.info({ userId: user.id, enabled }, 'Quiet hours configured')

return NextResponse.json({ preference })
} catch (error) {
logger.error({ err: error }, 'POST /quiet-hours error')
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
58 changes: 58 additions & 0 deletions tests/api.routes-b.trash-restore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { NextRequest } from 'next/server'

const verifyAuthToken = vi.fn()
const findUnique = vi.fn()
const trashFindFirst = vi.fn()
const transaction = vi.fn()

vi.mock('@/lib/auth', () => ({ verifyAuthToken }))
vi.mock('@/lib/db', () => ({
prisma: {
user: { findUnique },
trashItem: { findFirst: trashFindFirst },
$transaction: transaction,
},
}))
vi.mock('@/lib/logger', () => ({ logger: { info: vi.fn(), error: vi.fn() } }))

const URL = 'http://localhost/api/routes-b/trash/item-1/restore'

function req(token: string | null = 'tok') {
const h = new Headers()
if (token) h.set('authorization', `Bearer ${token}`)
return new NextRequest(URL, { method: 'POST', headers: h })
}

describe('POST /api/routes-b/trash/[id]/restore', () => {
beforeEach(() => vi.clearAllMocks())

it('returns 401 with invalid token', async () => {
verifyAuthToken.mockResolvedValue(null)
const { POST } = await import('@/app/api/routes-b/trash/[id]/restore/route')
const res = await POST(req(), { params: Promise.resolve({ id: 'item-1' }) })
expect(res.status).toBe(401)
})

it('returns 404 when trash item not found', async () => {
verifyAuthToken.mockResolvedValue({ userId: 'u1' })
findUnique.mockResolvedValue({ id: 'user-1' })
trashFindFirst.mockResolvedValue(null)
const { POST } = await import('@/app/api/routes-b/trash/[id]/restore/route')
const res = await POST(req(), { params: Promise.resolve({ id: 'item-1' }) })
expect(res.status).toBe(404)
})

it('restores item and returns 200', async () => {
verifyAuthToken.mockResolvedValue({ userId: 'u1' })
findUnique.mockResolvedValue({ id: 'user-1' })
trashFindFirst.mockResolvedValue({ id: 'item-1', resourceType: 'invoice', resourceId: 'inv-1', deletedAt: new Date() })
transaction.mockResolvedValue([])
const { POST } = await import('@/app/api/routes-b/trash/[id]/restore/route')
const res = await POST(req(), { params: Promise.resolve({ id: 'item-1' }) })
expect(res.status).toBe(200)
const json = await res.json()
expect(json.restored).toBe(true)
expect(json.resourceType).toBe('invoice')
})
})
60 changes: 60 additions & 0 deletions tests/api.routes-d.api-keys-revoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { NextRequest } from 'next/server'

const verifyAuthToken = vi.fn()
const findUnique = vi.fn()
const apiFindFirst = vi.fn()
const apiUpdate = vi.fn()

vi.mock('@/lib/auth', () => ({ verifyAuthToken }))
vi.mock('@/lib/db', () => ({
prisma: {
user: { findUnique },
apiKey: { findFirst: apiFindFirst, update: apiUpdate },
},
}))
vi.mock('@/lib/logger', () => ({ logger: { info: vi.fn(), error: vi.fn() } }))

const URL = 'http://localhost/api/routes-d/api-keys/key-1'

function req() {
return new NextRequest(URL, { method: 'DELETE', headers: { authorization: 'Bearer tok' } })
}

describe('DELETE /api/routes-d/api-keys/[id]', () => {
beforeEach(() => vi.clearAllMocks())

it('returns 401 with no token', async () => {
const { DELETE } = await import('@/app/api/routes-d/api-keys/[id]/route')
const res = await DELETE(new NextRequest(URL, { method: 'DELETE' }), { params: Promise.resolve({ id: 'key-1' }) })
expect(res.status).toBe(401)
})

it('returns 404 when key not found', async () => {
verifyAuthToken.mockResolvedValue({ userId: 'u1' })
findUnique.mockResolvedValue({ id: 'user-1' })
apiFindFirst.mockResolvedValue(null)
const { DELETE } = await import('@/app/api/routes-d/api-keys/[id]/route')
const res = await DELETE(req(), { params: Promise.resolve({ id: 'key-1' }) })
expect(res.status).toBe(404)
})

it('returns 409 when key already revoked', async () => {
verifyAuthToken.mockResolvedValue({ userId: 'u1' })
findUnique.mockResolvedValue({ id: 'user-1' })
apiFindFirst.mockResolvedValue({ id: 'key-1', revoked: true })
const { DELETE } = await import('@/app/api/routes-d/api-keys/[id]/route')
const res = await DELETE(req(), { params: Promise.resolve({ id: 'key-1' }) })
expect(res.status).toBe(409)
})

it('revokes the key and returns 204', async () => {
verifyAuthToken.mockResolvedValue({ userId: 'u1' })
findUnique.mockResolvedValue({ id: 'user-1' })
apiFindFirst.mockResolvedValue({ id: 'key-1', revoked: false })
apiUpdate.mockResolvedValue({})
const { DELETE } = await import('@/app/api/routes-d/api-keys/[id]/route')
const res = await DELETE(req(), { params: Promise.resolve({ id: 'key-1' }) })
expect(res.status).toBe(204)
})
})
52 changes: 52 additions & 0 deletions tests/api.routes-d.billing-usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { NextRequest } from 'next/server'

const verifyAuthToken = vi.fn()
const findUnique = vi.fn()
const invoiceCount = vi.fn()
const apiUsageCount = vi.fn()
const storageAggregate = vi.fn()

vi.mock('@/lib/auth', () => ({ verifyAuthToken }))
vi.mock('@/lib/db', () => ({
prisma: {
user: { findUnique },
invoice: { count: invoiceCount },
apiUsageLog: { count: apiUsageCount },
storageUsage: { aggregate: storageAggregate },
},
}))
vi.mock('@/lib/logger', () => ({ logger: { info: vi.fn(), error: vi.fn() } }))

const URL = 'http://localhost/api/routes-d/billing/usage'

function req() {
return new NextRequest(URL, { method: 'GET', headers: { authorization: 'Bearer tok' } })
}

describe('GET /api/routes-d/billing/usage', () => {
beforeEach(() => vi.clearAllMocks())

it('returns 401 with no token', async () => {
const { GET } = await import('@/app/api/routes-d/billing/usage/route')
const res = await GET(new NextRequest(URL, { method: 'GET' }))
expect(res.status).toBe(401)
})

it('returns usage data for free plan', async () => {
verifyAuthToken.mockResolvedValue({ userId: 'u1' })
findUnique.mockResolvedValue({ id: 'user-1', subscription: null })
invoiceCount.mockResolvedValue(3)
apiUsageCount.mockResolvedValue(150)
storageAggregate.mockResolvedValue({ _sum: { bytes: 5_242_880 } })

const { GET } = await import('@/app/api/routes-d/billing/usage/route')
const res = await GET(req())
expect(res.status).toBe(200)
const json = await res.json()
expect(json.plan).toBe('free')
expect(json.usage.invoices.used).toBe(3)
expect(json.usage.invoices.limit).toBe(10)
expect(json.usage.storageMb.used).toBe(5)
})
})
Loading
Loading