feat: Complete rewrite as Next.js admin dashboard
Major transformation from FastAPI telemetry service to Next.js admin dashboard: - Next.js 15 App Router with TypeScript - Prisma ORM with PostgreSQL (same schema, new client) - TanStack Query for data fetching - Tailwind CSS + shadcn/ui components - Admin dashboard with: - Dashboard stats overview - Customer management (list, detail, edit) - Order management (list, create, detail, logs) - Server monitoring (grid view) - Subscription management Pages implemented: - /admin (dashboard) - /admin/customers (list + [id] detail) - /admin/orders (list + [id] detail with SSE logs) - /admin/servers (grid view) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
150
src/app/api/v1/admin/customers/[id]/route.ts
Normal file
150
src/app/api/v1/admin/customers/[id]/route.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { UserStatus } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/customers/[id]
|
||||
* Get customer details with orders and subscriptions
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: customerId } = await params
|
||||
|
||||
const customer = await prisma.user.findUnique({
|
||||
where: { id: customerId },
|
||||
include: {
|
||||
subscriptions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
orders: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { provisioningLogs: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
tokenUsage: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
orders: true,
|
||||
subscriptions: true,
|
||||
tokenUsage: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!customer) {
|
||||
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Calculate total token usage
|
||||
const totalTokensUsed = customer.tokenUsage.reduce(
|
||||
(acc, usage) => acc + usage.tokensInput + usage.tokensOutput,
|
||||
0
|
||||
)
|
||||
|
||||
// Get current subscription's token limit
|
||||
const currentSubscription = customer.subscriptions[0]
|
||||
const tokenLimit = currentSubscription?.tokenLimit || 0
|
||||
|
||||
return NextResponse.json({
|
||||
...customer,
|
||||
totalTokensUsed,
|
||||
tokenLimit,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error getting customer:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get customer' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/admin/customers/[id]
|
||||
* Update customer details
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: customerId } = await params
|
||||
const body = await request.json()
|
||||
|
||||
// Validate customer exists
|
||||
const existingCustomer = await prisma.user.findUnique({
|
||||
where: { id: customerId },
|
||||
})
|
||||
|
||||
if (!existingCustomer) {
|
||||
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Build update data
|
||||
const updateData: {
|
||||
name?: string
|
||||
company?: string
|
||||
status?: UserStatus
|
||||
} = {}
|
||||
|
||||
if (body.name !== undefined) {
|
||||
updateData.name = body.name
|
||||
}
|
||||
|
||||
if (body.company !== undefined) {
|
||||
updateData.company = body.company
|
||||
}
|
||||
|
||||
if (body.status !== undefined) {
|
||||
updateData.status = body.status as UserStatus
|
||||
}
|
||||
|
||||
const customer = await prisma.user.update({
|
||||
where: { id: customerId },
|
||||
data: updateData,
|
||||
include: {
|
||||
subscriptions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
orders: true,
|
||||
subscriptions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(customer)
|
||||
} catch (error) {
|
||||
console.error('Error updating customer:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update customer' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
88
src/app/api/v1/admin/customers/route.ts
Normal file
88
src/app/api/v1/admin/customers/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { UserStatus, Prisma } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/customers
|
||||
* List all customers with optional filters
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const status = searchParams.get('status') as UserStatus | null
|
||||
const search = searchParams.get('search')
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
|
||||
const where: Prisma.UserWhereInput = {}
|
||||
|
||||
if (status) {
|
||||
where.status = status
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ company: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
const [customers, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
company: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
subscriptions: {
|
||||
select: {
|
||||
id: true,
|
||||
plan: true,
|
||||
tier: true,
|
||||
status: true,
|
||||
},
|
||||
take: 1,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
orders: true,
|
||||
subscriptions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.user.count({ where }),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
customers,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error listing customers:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list customers' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
148
src/app/api/v1/admin/orders/[id]/logs/stream/route.ts
Normal file
148
src/app/api/v1/admin/orders/[id]/logs/stream/route.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { OrderStatus } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/orders/[id]/logs/stream
|
||||
* Stream provisioning logs via Server-Sent Events
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId } = await params
|
||||
|
||||
// Verify order exists
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
return new Response('Order not found', { status: 404 })
|
||||
}
|
||||
|
||||
// Create SSE response
|
||||
const encoder = new TextEncoder()
|
||||
let lastLogId: string | null = null
|
||||
let isActive = true
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Send initial connection message
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: connected\ndata: ${JSON.stringify({ orderId })}\n\n`)
|
||||
)
|
||||
|
||||
// Poll for new logs
|
||||
const poll = async () => {
|
||||
if (!isActive) return
|
||||
|
||||
try {
|
||||
// Get current order status
|
||||
const currentOrder = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: { status: true },
|
||||
})
|
||||
|
||||
if (!currentOrder) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: error\ndata: ${JSON.stringify({ error: 'Order not found' })}\n\n`)
|
||||
)
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
// Build query for new logs
|
||||
const query: Parameters<typeof prisma.provisioningLog.findMany>[0] = {
|
||||
where: {
|
||||
orderId,
|
||||
...(lastLogId ? { id: { gt: lastLogId } } : {}),
|
||||
},
|
||||
orderBy: { timestamp: 'asc' as const },
|
||||
take: 50,
|
||||
}
|
||||
|
||||
const newLogs = await prisma.provisioningLog.findMany(query)
|
||||
|
||||
if (newLogs.length > 0) {
|
||||
// Update last seen log ID
|
||||
lastLogId = newLogs[newLogs.length - 1].id
|
||||
|
||||
// Send each log as an event
|
||||
for (const log of newLogs) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: log\ndata: ${JSON.stringify({
|
||||
id: log.id,
|
||||
level: log.level,
|
||||
step: log.step,
|
||||
message: log.message,
|
||||
timestamp: log.timestamp.toISOString(),
|
||||
})}\n\n`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Send status update
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: status\ndata: ${JSON.stringify({ status: currentOrder.status })}\n\n`)
|
||||
)
|
||||
|
||||
// Check if provisioning is complete
|
||||
const terminalStatuses: OrderStatus[] = [
|
||||
OrderStatus.FULFILLED,
|
||||
OrderStatus.EMAIL_CONFIGURED,
|
||||
OrderStatus.FAILED,
|
||||
]
|
||||
|
||||
if (terminalStatuses.includes(currentOrder.status)) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: complete\ndata: ${JSON.stringify({
|
||||
status: currentOrder.status,
|
||||
success: currentOrder.status !== OrderStatus.FAILED
|
||||
})}\n\n`)
|
||||
)
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
// Continue polling if still provisioning
|
||||
setTimeout(poll, 2000) // Poll every 2 seconds
|
||||
} catch (err) {
|
||||
console.error('SSE polling error:', err)
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: error\ndata: ${JSON.stringify({ error: 'Polling error' })}\n\n`)
|
||||
)
|
||||
controller.close()
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling
|
||||
poll()
|
||||
},
|
||||
cancel() {
|
||||
isActive = false
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error setting up SSE stream:', error)
|
||||
return new Response('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
68
src/app/api/v1/admin/orders/[id]/provision/route.ts
Normal file
68
src/app/api/v1/admin/orders/[id]/provision/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { OrderStatus } from '@prisma/client'
|
||||
import { jobService } from '@/lib/services/job-service'
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/orders/[id]/provision
|
||||
* Trigger provisioning for an order
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId } = await params
|
||||
|
||||
// Check if order exists and is ready for provisioning
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Validate order status - can only provision from DNS_READY or FAILED
|
||||
const validStatuses: OrderStatus[] = [OrderStatus.DNS_READY, OrderStatus.FAILED]
|
||||
if (!validStatuses.includes(order.status)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Cannot provision order in status ${order.status}. Must be DNS_READY or FAILED.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate server credentials
|
||||
if (!order.serverIp || !order.serverPasswordEncrypted) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server credentials not configured' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create provisioning job
|
||||
const result = await jobService.createJobForOrder(orderId)
|
||||
const { jobId } = JSON.parse(result)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Provisioning job created',
|
||||
jobId,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error triggering provisioning:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to trigger provisioning' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
175
src/app/api/v1/admin/orders/[id]/route.ts
Normal file
175
src/app/api/v1/admin/orders/[id]/route.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { OrderStatus } from '@prisma/client'
|
||||
import crypto from 'crypto'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/orders/[id]
|
||||
* Get order details
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId } = await params
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
company: true,
|
||||
},
|
||||
},
|
||||
provisioningLogs: {
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: 100,
|
||||
},
|
||||
jobs: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
attempt: true,
|
||||
maxAttempts: true,
|
||||
createdAt: true,
|
||||
completedAt: true,
|
||||
error: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(order)
|
||||
} catch (error) {
|
||||
console.error('Error getting order:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get order' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/admin/orders/[id]
|
||||
* Update order (status, server credentials, etc.)
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId } = await params
|
||||
const body = await request.json()
|
||||
|
||||
// Find existing order
|
||||
const existingOrder = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
})
|
||||
|
||||
if (!existingOrder) {
|
||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Build update data
|
||||
const updateData: {
|
||||
status?: OrderStatus
|
||||
serverIp?: string
|
||||
serverPasswordEncrypted?: string
|
||||
sshPort?: number
|
||||
serverReadyAt?: Date
|
||||
failureReason?: string
|
||||
} = {}
|
||||
|
||||
// Handle status update
|
||||
if (body.status) {
|
||||
updateData.status = body.status as OrderStatus
|
||||
}
|
||||
|
||||
// Handle server credentials
|
||||
if (body.serverIp) {
|
||||
updateData.serverIp = body.serverIp
|
||||
}
|
||||
|
||||
if (body.serverPassword) {
|
||||
// Encrypt the password before storing
|
||||
// TODO: Use proper encryption with environment-based key
|
||||
const encrypted = encryptPassword(body.serverPassword)
|
||||
updateData.serverPasswordEncrypted = encrypted
|
||||
}
|
||||
|
||||
if (body.sshPort) {
|
||||
updateData.sshPort = body.sshPort
|
||||
}
|
||||
|
||||
// If server credentials are being set and status is AWAITING_SERVER, move to SERVER_READY
|
||||
if (
|
||||
(body.serverIp || body.serverPassword) &&
|
||||
existingOrder.status === OrderStatus.AWAITING_SERVER
|
||||
) {
|
||||
updateData.status = OrderStatus.SERVER_READY
|
||||
updateData.serverReadyAt = new Date()
|
||||
}
|
||||
|
||||
// Update order
|
||||
const order = await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: updateData,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(order)
|
||||
} catch (error) {
|
||||
console.error('Error updating order:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update order' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to encrypt password
|
||||
function encryptPassword(password: string): string {
|
||||
// TODO: Implement proper encryption using environment-based key
|
||||
// For now, use a simple encryption for development
|
||||
const key = crypto.scryptSync(
|
||||
process.env.ENCRYPTION_KEY || 'dev-key-change-in-production',
|
||||
'salt',
|
||||
32
|
||||
)
|
||||
const iv = crypto.randomBytes(16)
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv)
|
||||
let encrypted = cipher.update(password, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
return iv.toString('hex') + ':' + encrypted
|
||||
}
|
||||
144
src/app/api/v1/admin/orders/route.ts
Normal file
144
src/app/api/v1/admin/orders/route.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { OrderStatus, SubscriptionTier, Prisma } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/orders
|
||||
* List all orders with optional filters
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
// Check authentication and authorization
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const status = searchParams.get('status') as OrderStatus | null
|
||||
const tier = searchParams.get('tier')
|
||||
const search = searchParams.get('search')
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
|
||||
// Build where clause
|
||||
const where: Prisma.OrderWhereInput = {}
|
||||
|
||||
if (status) {
|
||||
where.status = status
|
||||
}
|
||||
|
||||
if (tier && Object.values(SubscriptionTier).includes(tier as SubscriptionTier)) {
|
||||
where.tier = tier as SubscriptionTier
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ domain: { contains: search, mode: 'insensitive' } },
|
||||
{ user: { email: { contains: search, mode: 'insensitive' } } },
|
||||
]
|
||||
}
|
||||
|
||||
// Get orders with pagination
|
||||
const [orders, total] = await Promise.all([
|
||||
prisma.order.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
company: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { provisioningLogs: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.order.count({ where }),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
orders,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error listing orders:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list orders' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/orders
|
||||
* Create a new order (admin-initiated)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { userId, domain, tier, tools } = body
|
||||
|
||||
if (!userId || !domain || !tier || !tools) {
|
||||
return NextResponse.json(
|
||||
{ error: 'userId, domain, tier, and tools are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify user exists
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Create order
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
userId,
|
||||
domain,
|
||||
tier,
|
||||
tools,
|
||||
status: OrderStatus.PAYMENT_CONFIRMED,
|
||||
configJson: { tools, tier, domain },
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(order, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating order:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create order' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
126
src/app/api/v1/admin/servers/route.ts
Normal file
126
src/app/api/v1/admin/servers/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { OrderStatus, Prisma } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/servers
|
||||
* List all servers (orders with server credentials)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const status = searchParams.get('status')
|
||||
const search = searchParams.get('search')
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
|
||||
// Build where clause - only orders with serverIp are considered servers
|
||||
const where: Prisma.OrderWhereInput = {
|
||||
serverIp: { not: null },
|
||||
}
|
||||
|
||||
// Filter by server status (derived from order status)
|
||||
if (status === 'online') {
|
||||
where.status = { in: [OrderStatus.FULFILLED, OrderStatus.EMAIL_CONFIGURED] }
|
||||
} else if (status === 'provisioning') {
|
||||
where.status = { in: [OrderStatus.PROVISIONING, OrderStatus.DNS_READY, OrderStatus.SERVER_READY] }
|
||||
} else if (status === 'offline') {
|
||||
where.status = OrderStatus.FAILED
|
||||
}
|
||||
|
||||
// Search by domain or serverIp
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ domain: { contains: search, mode: 'insensitive' } },
|
||||
{ serverIp: { contains: search, mode: 'insensitive' } },
|
||||
{ user: { name: { contains: search, mode: 'insensitive' } } },
|
||||
{ user: { company: { contains: search, mode: 'insensitive' } } },
|
||||
]
|
||||
}
|
||||
|
||||
const [orders, total] = await Promise.all([
|
||||
prisma.order.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
domain: true,
|
||||
tier: true,
|
||||
status: true,
|
||||
serverIp: true,
|
||||
sshPort: true,
|
||||
tools: true,
|
||||
createdAt: true,
|
||||
serverReadyAt: true,
|
||||
completedAt: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
company: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.order.count({ where }),
|
||||
])
|
||||
|
||||
// Map orders to server format
|
||||
const servers = orders.map((order) => ({
|
||||
id: order.id,
|
||||
domain: order.domain,
|
||||
tier: order.tier,
|
||||
orderStatus: order.status,
|
||||
serverStatus: deriveServerStatus(order.status),
|
||||
serverIp: order.serverIp,
|
||||
sshPort: order.sshPort,
|
||||
tools: order.tools,
|
||||
createdAt: order.createdAt,
|
||||
serverReadyAt: order.serverReadyAt,
|
||||
completedAt: order.completedAt,
|
||||
customer: order.user,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
servers,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error listing servers:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list servers' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function deriveServerStatus(orderStatus: OrderStatus): 'online' | 'provisioning' | 'offline' | 'pending' {
|
||||
switch (orderStatus) {
|
||||
case OrderStatus.FULFILLED:
|
||||
case OrderStatus.EMAIL_CONFIGURED:
|
||||
return 'online'
|
||||
case OrderStatus.PROVISIONING:
|
||||
case OrderStatus.DNS_READY:
|
||||
case OrderStatus.SERVER_READY:
|
||||
return 'provisioning'
|
||||
case OrderStatus.FAILED:
|
||||
return 'offline'
|
||||
default:
|
||||
return 'pending'
|
||||
}
|
||||
}
|
||||
170
src/app/api/v1/admin/stats/route.ts
Normal file
170
src/app/api/v1/admin/stats/route.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { OrderStatus, SubscriptionPlan, SubscriptionTier, UserStatus, SubscriptionStatus } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/stats
|
||||
* Get dashboard statistics
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get order counts by status
|
||||
const ordersByStatus = await prisma.order.groupBy({
|
||||
by: ['status'],
|
||||
_count: { status: true },
|
||||
})
|
||||
|
||||
const orderStatusCounts: Record<OrderStatus, number> = Object.fromEntries(
|
||||
Object.values(OrderStatus).map((status) => [status, 0])
|
||||
) as Record<OrderStatus, number>
|
||||
|
||||
ordersByStatus.forEach((item) => {
|
||||
orderStatusCounts[item.status] = item._count.status
|
||||
})
|
||||
|
||||
// Calculate order statistics
|
||||
const pendingStatuses = [
|
||||
OrderStatus.PAYMENT_CONFIRMED,
|
||||
OrderStatus.AWAITING_SERVER,
|
||||
OrderStatus.SERVER_READY,
|
||||
OrderStatus.DNS_PENDING,
|
||||
OrderStatus.DNS_READY,
|
||||
]
|
||||
const inProgressStatuses = [OrderStatus.PROVISIONING]
|
||||
const completedStatuses = [OrderStatus.FULFILLED, OrderStatus.EMAIL_CONFIGURED]
|
||||
const failedStatuses = [OrderStatus.FAILED]
|
||||
|
||||
const ordersPending = pendingStatuses.reduce(
|
||||
(sum, status) => sum + orderStatusCounts[status],
|
||||
0
|
||||
)
|
||||
const ordersInProgress = inProgressStatuses.reduce(
|
||||
(sum, status) => sum + orderStatusCounts[status],
|
||||
0
|
||||
)
|
||||
const ordersCompleted = completedStatuses.reduce(
|
||||
(sum, status) => sum + orderStatusCounts[status],
|
||||
0
|
||||
)
|
||||
const ordersFailed = failedStatuses.reduce(
|
||||
(sum, status) => sum + orderStatusCounts[status],
|
||||
0
|
||||
)
|
||||
|
||||
// Get customer counts by status
|
||||
const customersByStatus = await prisma.user.groupBy({
|
||||
by: ['status'],
|
||||
_count: { status: true },
|
||||
})
|
||||
|
||||
const customerStatusCounts: Record<UserStatus, number> = Object.fromEntries(
|
||||
Object.values(UserStatus).map((status) => [status, 0])
|
||||
) as Record<UserStatus, number>
|
||||
|
||||
customersByStatus.forEach((item) => {
|
||||
customerStatusCounts[item.status] = item._count.status
|
||||
})
|
||||
|
||||
// Get subscription counts
|
||||
const subscriptionsByPlan = await prisma.subscription.groupBy({
|
||||
by: ['plan'],
|
||||
_count: { plan: true },
|
||||
})
|
||||
|
||||
const subscriptionsByTier = await prisma.subscription.groupBy({
|
||||
by: ['tier'],
|
||||
_count: { tier: true },
|
||||
})
|
||||
|
||||
const subscriptionsByStatusRaw = await prisma.subscription.groupBy({
|
||||
by: ['status'],
|
||||
_count: { status: true },
|
||||
})
|
||||
|
||||
const planCounts: Record<SubscriptionPlan, number> = Object.fromEntries(
|
||||
Object.values(SubscriptionPlan).map((plan) => [plan, 0])
|
||||
) as Record<SubscriptionPlan, number>
|
||||
|
||||
subscriptionsByPlan.forEach((item) => {
|
||||
planCounts[item.plan] = item._count.plan
|
||||
})
|
||||
|
||||
const tierCounts: Record<SubscriptionTier, number> = Object.fromEntries(
|
||||
Object.values(SubscriptionTier).map((tier) => [tier, 0])
|
||||
) as Record<SubscriptionTier, number>
|
||||
|
||||
subscriptionsByTier.forEach((item) => {
|
||||
tierCounts[item.tier] = item._count.tier
|
||||
})
|
||||
|
||||
const subscriptionStatusCounts: Record<SubscriptionStatus, number> = Object.fromEntries(
|
||||
Object.values(SubscriptionStatus).map((status) => [status, 0])
|
||||
) as Record<SubscriptionStatus, number>
|
||||
|
||||
subscriptionsByStatusRaw.forEach((item) => {
|
||||
subscriptionStatusCounts[item.status] = item._count.status
|
||||
})
|
||||
|
||||
// Get recent orders
|
||||
const recentOrders = await prisma.order.findMany({
|
||||
take: 5,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
company: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { provisioningLogs: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Calculate totals
|
||||
const ordersTotal = Object.values(orderStatusCounts).reduce((a, b) => a + b, 0)
|
||||
const customersTotal = Object.values(customerStatusCounts).reduce((a, b) => a + b, 0)
|
||||
const subscriptionsTotal = Object.values(planCounts).reduce((a, b) => a + b, 0)
|
||||
|
||||
return NextResponse.json({
|
||||
orders: {
|
||||
total: ordersTotal,
|
||||
pending: ordersPending,
|
||||
inProgress: ordersInProgress,
|
||||
completed: ordersCompleted,
|
||||
failed: ordersFailed,
|
||||
byStatus: orderStatusCounts,
|
||||
},
|
||||
customers: {
|
||||
total: customersTotal,
|
||||
active: customerStatusCounts[UserStatus.ACTIVE],
|
||||
suspended: customerStatusCounts[UserStatus.SUSPENDED],
|
||||
pending: customerStatusCounts[UserStatus.PENDING_VERIFICATION],
|
||||
},
|
||||
subscriptions: {
|
||||
total: subscriptionsTotal,
|
||||
trial: subscriptionStatusCounts[SubscriptionStatus.TRIAL],
|
||||
active: subscriptionStatusCounts[SubscriptionStatus.ACTIVE],
|
||||
byPlan: planCounts,
|
||||
byTier: tierCounts,
|
||||
},
|
||||
recentOrders,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error getting dashboard stats:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get dashboard stats' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user