Complete Hub Admin Dashboard with analytics, settings, and enterprise features
Major additions: - Analytics dashboard with charts (line, bar, donut) - Enterprise client monitoring with container management - Staff management with 2FA support - Profile management and settings pages - Netcup server integration - DNS verification panel - Portainer integration - Container logs and health monitoring - Automation controls for orders New API endpoints: - /api/v1/admin/analytics - /api/v1/admin/enterprise-clients - /api/v1/admin/netcup - /api/v1/admin/settings - /api/v1/admin/staff - /api/v1/profile Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
313
src/app/api/v1/admin/analytics/route.ts
Normal file
313
src/app/api/v1/admin/analytics/route.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireStaffPermission } from '@/lib/auth-helpers'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { OrderStatus, SubscriptionPlan, SubscriptionTier, UserStatus, SubscriptionStatus } from '@prisma/client'
|
||||
|
||||
type TimeRange = '7d' | '30d' | '90d'
|
||||
|
||||
function getDateRange(range: TimeRange): Date {
|
||||
const now = new Date()
|
||||
switch (range) {
|
||||
case '7d':
|
||||
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
case '30d':
|
||||
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
case '90d':
|
||||
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000)
|
||||
default:
|
||||
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviousRange(range: TimeRange): { start: Date; end: Date } {
|
||||
const now = new Date()
|
||||
const currentStart = getDateRange(range)
|
||||
const duration = now.getTime() - currentStart.getTime()
|
||||
return {
|
||||
start: new Date(currentStart.getTime() - duration),
|
||||
end: currentStart,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/analytics
|
||||
* Get comprehensive analytics data for the dashboard
|
||||
* Query params: range=7d|30d|90d (default: 30d)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
await requireStaffPermission('dashboard:view')
|
||||
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const range = (searchParams.get('range') || '30d') as TimeRange
|
||||
const startDate = getDateRange(range)
|
||||
const previousRange = getPreviousRange(range)
|
||||
|
||||
// === OVERVIEW METRICS ===
|
||||
|
||||
// Total orders (all time)
|
||||
const totalOrders = await prisma.order.count()
|
||||
|
||||
// Orders in current period
|
||||
const currentPeriodOrders = await prisma.order.count({
|
||||
where: { createdAt: { gte: startDate } },
|
||||
})
|
||||
|
||||
// Orders in previous period
|
||||
const previousPeriodOrders = await prisma.order.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: previousRange.start,
|
||||
lt: previousRange.end,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Active customers
|
||||
const activeCustomers = await prisma.user.count({
|
||||
where: { status: UserStatus.ACTIVE },
|
||||
})
|
||||
|
||||
// Current period new customers
|
||||
const currentPeriodCustomers = await prisma.user.count({
|
||||
where: { createdAt: { gte: startDate } },
|
||||
})
|
||||
|
||||
// Previous period new customers
|
||||
const previousPeriodCustomers = await prisma.user.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: previousRange.start,
|
||||
lt: previousRange.end,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Active subscriptions
|
||||
const activeSubscriptions = await prisma.subscription.count({
|
||||
where: { status: { in: [SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIAL] } },
|
||||
})
|
||||
|
||||
// Success rate (fulfilled / (fulfilled + failed))
|
||||
const fulfilledOrders = await prisma.order.count({
|
||||
where: { status: { in: [OrderStatus.FULFILLED, OrderStatus.EMAIL_CONFIGURED] } },
|
||||
})
|
||||
const failedOrders = await prisma.order.count({
|
||||
where: { status: OrderStatus.FAILED },
|
||||
})
|
||||
const successRate = fulfilledOrders + failedOrders > 0
|
||||
? (fulfilledOrders / (fulfilledOrders + failedOrders)) * 100
|
||||
: 100
|
||||
|
||||
// === ORDERS BY DAY ===
|
||||
const ordersByDay = await prisma.$queryRaw<{ date: Date; count: bigint }[]>`
|
||||
SELECT DATE(created_at) as date, COUNT(*) as count
|
||||
FROM orders
|
||||
WHERE created_at >= ${startDate}
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
`
|
||||
|
||||
// === ORDERS BY STATUS ===
|
||||
const ordersByStatus = await prisma.order.groupBy({
|
||||
by: ['status'],
|
||||
_count: { status: true },
|
||||
})
|
||||
|
||||
const statusCounts: Record<string, number> = {}
|
||||
Object.values(OrderStatus).forEach((status) => {
|
||||
statusCounts[status] = 0
|
||||
})
|
||||
ordersByStatus.forEach((item) => {
|
||||
statusCounts[item.status] = item._count.status
|
||||
})
|
||||
|
||||
// === CUSTOMER GROWTH BY DAY ===
|
||||
const customerGrowth = await prisma.$queryRaw<{ date: Date; count: bigint }[]>`
|
||||
SELECT DATE(created_at) as date, COUNT(*) as count
|
||||
FROM users
|
||||
WHERE created_at >= ${startDate}
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
`
|
||||
|
||||
// === SUBSCRIPTIONS BY PLAN ===
|
||||
const subscriptionsByPlan = await prisma.subscription.groupBy({
|
||||
by: ['plan'],
|
||||
_count: { plan: true },
|
||||
})
|
||||
|
||||
const planCounts: Record<string, number> = {}
|
||||
Object.values(SubscriptionPlan).forEach((plan) => {
|
||||
planCounts[plan] = 0
|
||||
})
|
||||
subscriptionsByPlan.forEach((item) => {
|
||||
planCounts[item.plan] = item._count.plan
|
||||
})
|
||||
|
||||
// === SUBSCRIPTIONS BY TIER ===
|
||||
const subscriptionsByTier = await prisma.subscription.groupBy({
|
||||
by: ['tier'],
|
||||
_count: { tier: true },
|
||||
})
|
||||
|
||||
const tierCounts: Record<string, number> = {}
|
||||
Object.values(SubscriptionTier).forEach((tier) => {
|
||||
tierCounts[tier] = 0
|
||||
})
|
||||
subscriptionsByTier.forEach((item) => {
|
||||
tierCounts[item.tier] = item._count.tier
|
||||
})
|
||||
|
||||
// === TOKEN USAGE BY DAY ===
|
||||
const tokenUsageByDay = await prisma.$queryRaw<{ date: Date; tokens: bigint }[]>`
|
||||
SELECT DATE(created_at) as date,
|
||||
SUM(tokens_input + tokens_output) as tokens
|
||||
FROM token_usage
|
||||
WHERE created_at >= ${startDate}
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
`
|
||||
|
||||
// === TOKEN USAGE BY OPERATION ===
|
||||
const tokensByOperation = await prisma.$queryRaw<{ operation: string; tokens: bigint }[]>`
|
||||
SELECT operation, SUM(tokens_input + tokens_output) as tokens
|
||||
FROM token_usage
|
||||
WHERE created_at >= ${startDate}
|
||||
GROUP BY operation
|
||||
ORDER BY tokens DESC
|
||||
`
|
||||
|
||||
// === TOP TOKEN CONSUMERS ===
|
||||
const topConsumers = await prisma.$queryRaw<{ userId: string; tokens: bigint }[]>`
|
||||
SELECT user_id as "userId", SUM(tokens_input + tokens_output) as tokens
|
||||
FROM token_usage
|
||||
WHERE created_at >= ${startDate}
|
||||
GROUP BY user_id
|
||||
ORDER BY tokens DESC
|
||||
LIMIT 10
|
||||
`
|
||||
|
||||
// Get customer names for top consumers
|
||||
const consumerIds = topConsumers.map((c) => c.userId)
|
||||
const consumers = await prisma.user.findMany({
|
||||
where: { id: { in: consumerIds } },
|
||||
select: { id: true, name: true, email: true, company: true },
|
||||
})
|
||||
|
||||
const consumerMap = new Map(consumers.map((c) => [c.id, c]))
|
||||
const topConsumersWithNames = topConsumers.map((c) => {
|
||||
const user = consumerMap.get(c.userId)
|
||||
return {
|
||||
userId: c.userId,
|
||||
name: user?.name || user?.company || user?.email || 'Unknown',
|
||||
tokens: Number(c.tokens),
|
||||
}
|
||||
})
|
||||
|
||||
// === PROVISIONING METRICS ===
|
||||
|
||||
// Recent failures
|
||||
const recentFailures = await prisma.order.findMany({
|
||||
where: {
|
||||
status: OrderStatus.FAILED,
|
||||
updatedAt: { gte: startDate },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
domain: true,
|
||||
updatedAt: true,
|
||||
provisioningLogs: {
|
||||
where: { level: 'ERROR' },
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: 1,
|
||||
select: { message: true },
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 10,
|
||||
})
|
||||
|
||||
// Orders by automation mode
|
||||
const ordersByAutomation = await prisma.order.groupBy({
|
||||
by: ['automationMode'],
|
||||
_count: { automationMode: true },
|
||||
})
|
||||
|
||||
const automationCounts: Record<string, number> = {
|
||||
AUTO: 0,
|
||||
MANUAL: 0,
|
||||
PAUSED: 0,
|
||||
}
|
||||
ordersByAutomation.forEach((item) => {
|
||||
automationCounts[item.automationMode] = item._count.automationMode
|
||||
})
|
||||
|
||||
// Calculate trends
|
||||
const ordersTrend = previousPeriodOrders > 0
|
||||
? ((currentPeriodOrders - previousPeriodOrders) / previousPeriodOrders) * 100
|
||||
: currentPeriodOrders > 0 ? 100 : 0
|
||||
|
||||
const customersTrend = previousPeriodCustomers > 0
|
||||
? ((currentPeriodCustomers - previousPeriodCustomers) / previousPeriodCustomers) * 100
|
||||
: currentPeriodCustomers > 0 ? 100 : 0
|
||||
|
||||
return NextResponse.json({
|
||||
range,
|
||||
overview: {
|
||||
totalOrders,
|
||||
ordersTrend: Math.round(ordersTrend * 10) / 10,
|
||||
activeCustomers,
|
||||
customersTrend: Math.round(customersTrend * 10) / 10,
|
||||
activeSubscriptions,
|
||||
successRate: Math.round(successRate * 10) / 10,
|
||||
},
|
||||
orders: {
|
||||
byDay: ordersByDay.map((row) => ({
|
||||
date: row.date.toISOString().split('T')[0],
|
||||
count: Number(row.count),
|
||||
})),
|
||||
byStatus: statusCounts,
|
||||
},
|
||||
customers: {
|
||||
growthByDay: customerGrowth.map((row) => ({
|
||||
date: row.date.toISOString().split('T')[0],
|
||||
count: Number(row.count),
|
||||
})),
|
||||
byPlan: planCounts,
|
||||
byTier: tierCounts,
|
||||
},
|
||||
tokens: {
|
||||
usageByDay: tokenUsageByDay.map((row) => ({
|
||||
date: row.date.toISOString().split('T')[0],
|
||||
tokens: Number(row.tokens),
|
||||
})),
|
||||
byOperation: tokensByOperation.map((row) => ({
|
||||
operation: row.operation,
|
||||
tokens: Number(row.tokens),
|
||||
})),
|
||||
topConsumers: topConsumersWithNames,
|
||||
},
|
||||
provisioning: {
|
||||
successRate: Math.round(successRate * 10) / 10,
|
||||
byAutomation: automationCounts,
|
||||
recentFailures: recentFailures.map((order) => ({
|
||||
orderId: order.id,
|
||||
domain: order.domain,
|
||||
date: order.updatedAt.toISOString(),
|
||||
reason: order.provisioningLogs[0]?.message || 'Unknown error',
|
||||
})),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'status' in error) {
|
||||
const err = error as { status: number; message: string }
|
||||
return NextResponse.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
console.error('Error fetching analytics:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch analytics' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -148,3 +148,97 @@ export async function PATCH(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/customers/[id]
|
||||
* Delete a customer and all related records (orders, subscriptions, token usage)
|
||||
* Does NOT touch any actual servers - just removes from Hub database
|
||||
*/
|
||||
export async function DELETE(
|
||||
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
|
||||
|
||||
// Find existing customer with their orders
|
||||
const existingCustomer = await prisma.user.findUnique({
|
||||
where: { id: customerId },
|
||||
include: {
|
||||
orders: {
|
||||
include: {
|
||||
dnsVerification: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!existingCustomer) {
|
||||
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Note: Staff users are in a separate table, so this endpoint only handles customers
|
||||
|
||||
// Delete in correct order to respect foreign key constraints
|
||||
// 1. For each order, delete related records
|
||||
for (const order of existingCustomer.orders) {
|
||||
// Delete DNS records and verification
|
||||
if (order.dnsVerification) {
|
||||
await prisma.dnsRecord.deleteMany({
|
||||
where: { dnsVerificationId: order.dnsVerification.id },
|
||||
})
|
||||
await prisma.dnsVerification.delete({
|
||||
where: { id: order.dnsVerification.id },
|
||||
})
|
||||
}
|
||||
|
||||
// Delete provisioning logs
|
||||
await prisma.provisioningLog.deleteMany({
|
||||
where: { orderId: order.id },
|
||||
})
|
||||
|
||||
// Delete jobs
|
||||
await prisma.provisioningJob.deleteMany({
|
||||
where: { orderId: order.id },
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Delete all orders
|
||||
await prisma.order.deleteMany({
|
||||
where: { userId: customerId },
|
||||
})
|
||||
|
||||
// 3. Delete subscriptions
|
||||
await prisma.subscription.deleteMany({
|
||||
where: { userId: customerId },
|
||||
})
|
||||
|
||||
// 4. Delete token usage records
|
||||
await prisma.tokenUsage.deleteMany({
|
||||
where: { userId: customerId },
|
||||
})
|
||||
|
||||
// 5. Delete the customer/user
|
||||
await prisma.user.delete({
|
||||
where: { id: customerId },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Customer ${existingCustomer.email} and all related records deleted`,
|
||||
deletedOrders: existingCustomer.orders.length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting customer:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete customer' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,14 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { UserStatus, Prisma } from '@prisma/client'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
interface CreateCustomerRequest {
|
||||
email: string
|
||||
name?: string
|
||||
company?: string
|
||||
status?: UserStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/customers
|
||||
@@ -86,3 +94,84 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/customers
|
||||
* Create a new customer
|
||||
*/
|
||||
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: CreateCustomerRequest = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: body.email },
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A customer with this email already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate a random password (customer will need to reset it)
|
||||
const tempPassword = Math.random().toString(36).slice(-12)
|
||||
const passwordHash = await bcrypt.hash(tempPassword, 10)
|
||||
|
||||
// Create the customer
|
||||
const customer = await prisma.user.create({
|
||||
data: {
|
||||
email: body.email,
|
||||
name: body.name || null,
|
||||
company: body.company || null,
|
||||
status: body.status || 'PENDING_VERIFICATION',
|
||||
passwordHash,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
company: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
subscriptions: {
|
||||
select: {
|
||||
id: true,
|
||||
plan: true,
|
||||
tier: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
orders: true,
|
||||
subscriptions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(customer, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating customer:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create customer' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { containerHealthService } from '@/lib/services/container-health-service'
|
||||
import type { ContainerEventType } from '@prisma/client'
|
||||
|
||||
// GET /api/v1/admin/enterprise-clients/[id]/container-events
|
||||
// List container events (crashes, restarts, etc.) for a client
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
// Verify client exists
|
||||
const client = await prisma.enterpriseClient.findUnique({
|
||||
where: { id: clientId },
|
||||
})
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Parse filters
|
||||
const eventType = searchParams.get('type') as ContainerEventType | undefined
|
||||
const serverId = searchParams.get('serverId') || undefined
|
||||
const limit = parseInt(searchParams.get('limit') || '50', 10)
|
||||
const offset = parseInt(searchParams.get('offset') || '0', 10)
|
||||
|
||||
try {
|
||||
const result = await containerHealthService.getUnacknowledgedEvents(clientId, {
|
||||
eventType,
|
||||
serverId,
|
||||
limit: Math.min(limit, 200),
|
||||
offset,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
events: result.events,
|
||||
total: result.total,
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + result.events.length < result.total,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to get container events:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get container events' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/enterprise-clients/[id]/container-events
|
||||
// Acknowledge container events
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId } = await params
|
||||
|
||||
// Verify client exists
|
||||
const client = await prisma.enterpriseClient.findUnique({
|
||||
where: { id: clientId },
|
||||
})
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { eventIds } = body as { eventIds: string[] }
|
||||
|
||||
if (!eventIds || !Array.isArray(eventIds) || eventIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'eventIds array is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify events belong to this client
|
||||
const events = await prisma.containerEvent.findMany({
|
||||
where: {
|
||||
id: { in: eventIds },
|
||||
server: { clientId },
|
||||
},
|
||||
})
|
||||
|
||||
if (events.length !== eventIds.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Some events not found or do not belong to this client' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = session.user.email || 'unknown'
|
||||
const acknowledgedCount = await containerHealthService.acknowledgeEvents(eventIds, userId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
acknowledged: acknowledgedCount,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to acknowledge container events:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to acknowledge container events' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { errorDashboardService } from '@/lib/services/error-dashboard-service'
|
||||
|
||||
// GET /api/v1/admin/enterprise-clients/[id]/error-dashboard
|
||||
// Get aggregated error dashboard data for a client
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId } = await params
|
||||
|
||||
// Verify client exists
|
||||
const client = await prisma.enterpriseClient.findUnique({
|
||||
where: { id: clientId },
|
||||
})
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const dashboard = await errorDashboardService.getClientDashboard(clientId)
|
||||
return NextResponse.json(dashboard)
|
||||
} catch (error) {
|
||||
console.error('Failed to get error dashboard:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get error dashboard' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
getErrorRule,
|
||||
updateErrorRule,
|
||||
deleteErrorRule,
|
||||
} from '@/lib/services/error-detection-service'
|
||||
import type { ErrorSeverity } from '@prisma/client'
|
||||
|
||||
// GET /api/v1/admin/enterprise-clients/[id]/error-rules/[ruleId]
|
||||
// Get a specific error detection rule
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; ruleId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, ruleId } = await params
|
||||
|
||||
// Verify rule belongs to client
|
||||
const rule = await prisma.errorDetectionRule.findFirst({
|
||||
where: {
|
||||
id: ruleId,
|
||||
clientId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!rule) {
|
||||
return NextResponse.json({ error: 'Rule not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const ruleWithCount = await getErrorRule(ruleId)
|
||||
return NextResponse.json(ruleWithCount)
|
||||
} catch (error) {
|
||||
console.error('Failed to get error rule:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get error rule' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/v1/admin/enterprise-clients/[id]/error-rules/[ruleId]
|
||||
// Update an error detection rule
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; ruleId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, ruleId } = await params
|
||||
const body = await request.json()
|
||||
|
||||
// Verify rule belongs to client
|
||||
const existingRule = await prisma.errorDetectionRule.findFirst({
|
||||
where: {
|
||||
id: ruleId,
|
||||
clientId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!existingRule) {
|
||||
return NextResponse.json({ error: 'Rule not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Validate severity if provided
|
||||
const validSeverities: ErrorSeverity[] = ['INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||
if (body.severity && !validSeverities.includes(body.severity)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid severity. Must be one of: INFO, WARNING, ERROR, CRITICAL' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const rule = await updateErrorRule(ruleId, {
|
||||
name: body.name,
|
||||
pattern: body.pattern,
|
||||
severity: body.severity,
|
||||
description: body.description,
|
||||
isActive: body.isActive,
|
||||
})
|
||||
|
||||
return NextResponse.json(rule)
|
||||
} catch (error) {
|
||||
console.error('Failed to update error rule:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to update error rule' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/enterprise-clients/[id]/error-rules/[ruleId]
|
||||
// Delete an error detection rule
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; ruleId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, ruleId } = await params
|
||||
|
||||
// Verify rule belongs to client
|
||||
const rule = await prisma.errorDetectionRule.findFirst({
|
||||
where: {
|
||||
id: ruleId,
|
||||
clientId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!rule) {
|
||||
return NextResponse.json({ error: 'Rule not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteErrorRule(ruleId)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to delete error rule:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete error rule' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
getErrorRules,
|
||||
createErrorRule,
|
||||
seedDefaultRules,
|
||||
} from '@/lib/services/error-detection-service'
|
||||
import type { ErrorSeverity } from '@prisma/client'
|
||||
|
||||
// GET /api/v1/admin/enterprise-clients/[id]/error-rules
|
||||
// List all error detection rules for a client
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId } = await params
|
||||
|
||||
// Verify client exists
|
||||
const client = await prisma.enterpriseClient.findUnique({
|
||||
where: { id: clientId },
|
||||
})
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const rules = await getErrorRules(clientId)
|
||||
return NextResponse.json(rules)
|
||||
} catch (error) {
|
||||
console.error('Failed to get error rules:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get error rules' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/enterprise-clients/[id]/error-rules
|
||||
// Create a new error detection rule
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId } = await params
|
||||
const body = await request.json()
|
||||
|
||||
// Verify client exists
|
||||
const client = await prisma.enterpriseClient.findUnique({
|
||||
where: { id: clientId },
|
||||
})
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if this is a request to seed default rules
|
||||
if (body.seedDefaults === true) {
|
||||
try {
|
||||
const count = await seedDefaultRules(clientId)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Seeded ${count} default rules`,
|
||||
count,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to seed default rules:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to seed default rules' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!body.name || typeof body.name !== 'string') {
|
||||
return NextResponse.json({ error: 'Name is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!body.pattern || typeof body.pattern !== 'string') {
|
||||
return NextResponse.json({ error: 'Pattern is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate severity if provided
|
||||
const validSeverities: ErrorSeverity[] = ['INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||
if (body.severity && !validSeverities.includes(body.severity)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid severity. Must be one of: INFO, WARNING, ERROR, CRITICAL' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const rule = await createErrorRule(clientId, {
|
||||
name: body.name,
|
||||
pattern: body.pattern,
|
||||
severity: body.severity,
|
||||
description: body.description,
|
||||
})
|
||||
|
||||
return NextResponse.json(rule, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Failed to create error rule:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to create error rule' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { acknowledgeError } from '@/lib/services/error-detection-service'
|
||||
|
||||
// POST /api/v1/admin/enterprise-clients/[id]/errors/[errorId]/acknowledge
|
||||
// Acknowledge a detected error
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; errorId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, errorId } = await params
|
||||
|
||||
// Verify error belongs to a server owned by this client
|
||||
const error = await prisma.detectedError.findFirst({
|
||||
where: {
|
||||
id: errorId,
|
||||
server: {
|
||||
clientId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!error) {
|
||||
return NextResponse.json({ error: 'Error not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (error.acknowledgedAt) {
|
||||
return NextResponse.json({ error: 'Error already acknowledged' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = session.user.id || 'unknown'
|
||||
await acknowledgeError(errorId, userId)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (err) {
|
||||
console.error('Failed to acknowledge error:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to acknowledge error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
57
src/app/api/v1/admin/enterprise-clients/[id]/errors/route.ts
Normal file
57
src/app/api/v1/admin/enterprise-clients/[id]/errors/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getDetectedErrors } from '@/lib/services/error-detection-service'
|
||||
import type { ErrorSeverity } from '@prisma/client'
|
||||
|
||||
// GET /api/v1/admin/enterprise-clients/[id]/errors
|
||||
// List detected errors for a client
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
// Verify client exists
|
||||
const client = await prisma.enterpriseClient.findUnique({
|
||||
where: { id: clientId },
|
||||
})
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Parse filters
|
||||
const serverId = searchParams.get('serverId') || undefined
|
||||
const severity = searchParams.get('severity') as ErrorSeverity | undefined
|
||||
const acknowledgedParam = searchParams.get('acknowledged')
|
||||
const acknowledged = acknowledgedParam === 'true' ? true : acknowledgedParam === 'false' ? false : undefined
|
||||
const ruleId = searchParams.get('ruleId') || undefined
|
||||
const limit = parseInt(searchParams.get('limit') || '100', 10)
|
||||
const offset = parseInt(searchParams.get('offset') || '0', 10)
|
||||
|
||||
try {
|
||||
const errors = await getDetectedErrors(clientId, {
|
||||
serverId,
|
||||
severity,
|
||||
acknowledged,
|
||||
ruleId,
|
||||
limit: Math.min(limit, 500), // Cap at 500
|
||||
offset,
|
||||
})
|
||||
|
||||
return NextResponse.json(errors)
|
||||
} catch (error) {
|
||||
console.error('Failed to get detected errors:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get detected errors' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { notificationService } from '@/lib/services/notification-service'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Validation schema for updating notification settings
|
||||
const updateNotificationSettingsSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
criticalErrorsOnly: z.boolean().optional(),
|
||||
containerCrashes: z.boolean().optional(),
|
||||
recipients: z.array(z.string().email()).optional(),
|
||||
cooldownMinutes: z.number().min(5).max(1440).optional(), // 5 min to 24 hours
|
||||
})
|
||||
|
||||
// GET /api/v1/admin/enterprise-clients/[id]/notifications
|
||||
// Get notification settings for a client
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId } = await params
|
||||
|
||||
try {
|
||||
const settings = await notificationService.getNotificationSettings(clientId)
|
||||
|
||||
return NextResponse.json({
|
||||
enabled: settings.enabled,
|
||||
criticalErrorsOnly: settings.criticalErrorsOnly,
|
||||
containerCrashes: settings.containerCrashes,
|
||||
recipients: settings.recipients,
|
||||
cooldownMinutes: settings.cooldownMinutes,
|
||||
lastNotifiedAt: settings.lastNotifiedAt,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[API] Error fetching notification settings:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch notification settings' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/v1/admin/enterprise-clients/[id]/notifications
|
||||
// Update notification settings for a client
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId } = await params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const parsed = updateNotificationSettingsSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.format() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const settings = await notificationService.updateNotificationSettings(
|
||||
clientId,
|
||||
parsed.data
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
enabled: settings.enabled,
|
||||
criticalErrorsOnly: settings.criticalErrorsOnly,
|
||||
containerCrashes: settings.containerCrashes,
|
||||
recipients: settings.recipients,
|
||||
cooldownMinutes: settings.cooldownMinutes,
|
||||
lastNotifiedAt: settings.lastNotifiedAt,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[API] Error updating notification settings:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update notification settings' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
131
src/app/api/v1/admin/enterprise-clients/[id]/route.ts
Normal file
131
src/app/api/v1/admin/enterprise-clients/[id]/route.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { enterpriseClientService } from '@/lib/services/enterprise-client-service'
|
||||
import { z } from 'zod'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
const updateClientSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
companyName: z.string().optional().nullable(),
|
||||
contactEmail: z.string().email().optional(),
|
||||
contactPhone: z.string().optional().nullable(),
|
||||
notes: z.string().optional().nullable(),
|
||||
isActive: z.boolean().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/enterprise-clients/[id]
|
||||
* Get enterprise client details with servers and stats overview
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await context.params
|
||||
|
||||
try {
|
||||
const [client, statsOverview] = await Promise.all([
|
||||
enterpriseClientService.getClient(id),
|
||||
enterpriseClientService.getClientStatsOverview(id)
|
||||
])
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...client,
|
||||
statsOverview
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to get enterprise client:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get enterprise client' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/admin/enterprise-clients/[id]
|
||||
* Update enterprise client
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await context.params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const validation = updateClientSchema.safeParse(body)
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: validation.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if client exists
|
||||
const existingClient = await enterpriseClientService.getClient(id)
|
||||
if (!existingClient) {
|
||||
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const client = await enterpriseClientService.updateClient(id, validation.data)
|
||||
return NextResponse.json(client)
|
||||
} catch (error) {
|
||||
console.error('Failed to update enterprise client:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update enterprise client' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/enterprise-clients/[id]
|
||||
* Delete enterprise client
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await context.params
|
||||
|
||||
try {
|
||||
// Check if client exists
|
||||
const existingClient = await enterpriseClientService.getClient(id)
|
||||
if (!existingClient) {
|
||||
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await enterpriseClientService.deleteClient(id)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to delete enterprise client:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete enterprise client' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { enterpriseClientService } from '@/lib/services/enterprise-client-service'
|
||||
import { securityVerificationService } from '@/lib/services/security-verification-service'
|
||||
import { netcupService } from '@/lib/services/netcup-service'
|
||||
import { z } from 'zod'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string; serverId: string }>
|
||||
}
|
||||
|
||||
const powerActionSchema = z.object({
|
||||
action: z.literal('power'),
|
||||
command: z.enum(['ON', 'OFF', 'POWERCYCLE', 'RESET', 'POWEROFF'])
|
||||
})
|
||||
|
||||
const verifiedActionSchema = z.object({
|
||||
action: z.enum(['wipe', 'reinstall']),
|
||||
verificationCode: z.string().length(6, 'Verification code must be 6 digits'),
|
||||
imageId: z.string().optional() // Required for reinstall
|
||||
})
|
||||
|
||||
const actionSchema = z.discriminatedUnion('action', [
|
||||
powerActionSchema,
|
||||
verifiedActionSchema.extend({ action: z.literal('wipe') }),
|
||||
verifiedActionSchema.extend({ action: z.literal('reinstall'), imageId: z.string() })
|
||||
])
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/actions
|
||||
* Perform server action (power control or verified wipe/reinstall)
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, serverId } = await context.params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const validation = actionSchema.safeParse(body)
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: validation.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if server exists and belongs to client
|
||||
const server = await enterpriseClientService.getServer(clientId, serverId)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const data = validation.data
|
||||
|
||||
// Handle power actions (no verification needed)
|
||||
if (data.action === 'power') {
|
||||
await netcupService.powerAction(server.netcupServerId, data.command)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Power action ${data.command} initiated`
|
||||
})
|
||||
}
|
||||
|
||||
// Handle verified actions (wipe/reinstall)
|
||||
if (data.action === 'wipe' || data.action === 'reinstall') {
|
||||
// Verify the code
|
||||
const verifyResult = await securityVerificationService.verifyCode(
|
||||
clientId,
|
||||
data.verificationCode
|
||||
)
|
||||
|
||||
if (!verifyResult.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: verifyResult.errorMessage || 'Invalid verification code' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Ensure the code was for the correct action and server
|
||||
if (verifyResult.action?.toLowerCase() !== data.action) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Verification code was issued for a different action' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (verifyResult.serverId !== serverId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Verification code was issued for a different server' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Execute the action
|
||||
if (data.action === 'reinstall' && 'imageId' in data) {
|
||||
const task = await netcupService.reinstallServer(
|
||||
server.netcupServerId,
|
||||
data.imageId
|
||||
)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Server reinstall initiated',
|
||||
taskId: task.taskId
|
||||
})
|
||||
} else if (data.action === 'wipe') {
|
||||
// Wipe is essentially a reinstall with the same image
|
||||
// First get available images
|
||||
const images = await netcupService.getImageFlavours(server.netcupServerId)
|
||||
const defaultImage = images.find(img => img.name.toLowerCase().includes('debian')) || images[0]
|
||||
|
||||
if (!defaultImage) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No image available for wipe operation' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const task = await netcupService.reinstallServer(
|
||||
server.netcupServerId,
|
||||
defaultImage.id
|
||||
)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Server wipe initiated',
|
||||
taskId: task.taskId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error('Failed to execute server action:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to execute action' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { createPortainerClientForServer } from '@/lib/services/portainer-client'
|
||||
|
||||
// GET /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]/logs
|
||||
// Get container logs
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; serverId: string; containerId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, serverId, containerId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tail = parseInt(searchParams.get('tail') || '500', 10)
|
||||
|
||||
// Verify server belongs to client
|
||||
const server = await prisma.enterpriseServer.findFirst({
|
||||
where: {
|
||||
id: serverId,
|
||||
clientId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const portainerClient = await createPortainerClientForServer(serverId)
|
||||
if (!portainerClient) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Portainer not configured for this server' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const logs = await portainerClient.getContainerLogs(containerId, tail)
|
||||
|
||||
return NextResponse.json({
|
||||
containerId,
|
||||
tail,
|
||||
logs,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to get container logs:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to get container logs' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { createPortainerClientForServer } from '@/lib/services/portainer-client'
|
||||
|
||||
// GET /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]
|
||||
// Get container details
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; serverId: string; containerId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, serverId, containerId } = await params
|
||||
|
||||
// Verify server belongs to client
|
||||
const server = await prisma.enterpriseServer.findFirst({
|
||||
where: {
|
||||
id: serverId,
|
||||
clientId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const portainerClient = await createPortainerClientForServer(serverId)
|
||||
if (!portainerClient) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Portainer not configured for this server' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const container = await portainerClient.getContainer(containerId)
|
||||
const stats = await portainerClient.getContainerStats(containerId)
|
||||
|
||||
return NextResponse.json({
|
||||
id: container.Id,
|
||||
name: container.Name.replace(/^\//, ''),
|
||||
image: container.Image,
|
||||
created: container.Created,
|
||||
state: container.State,
|
||||
config: {
|
||||
hostname: container.Config.Hostname,
|
||||
env: container.Config.Env,
|
||||
image: container.Config.Image,
|
||||
workingDir: container.Config.WorkingDir,
|
||||
},
|
||||
hostConfig: {
|
||||
restartPolicy: container.HostConfig.RestartPolicy,
|
||||
},
|
||||
networkSettings: {
|
||||
networks: container.NetworkSettings.Networks,
|
||||
ports: container.NetworkSettings.Ports,
|
||||
},
|
||||
stats,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to get container:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to get container' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]
|
||||
// Remove a container
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; serverId: string; containerId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, serverId, containerId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const force = searchParams.get('force') === 'true'
|
||||
|
||||
// Verify server belongs to client
|
||||
const server = await prisma.enterpriseServer.findFirst({
|
||||
where: {
|
||||
id: serverId,
|
||||
clientId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const portainerClient = await createPortainerClientForServer(serverId)
|
||||
if (!portainerClient) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Portainer not configured for this server' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await portainerClient.removeContainer(containerId, force)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Container removed successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to remove container:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to remove container' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]
|
||||
// Perform container action (start, stop, restart)
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; serverId: string; containerId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, serverId, containerId } = await params
|
||||
const body = await request.json()
|
||||
const action = body.action as 'start' | 'stop' | 'restart'
|
||||
|
||||
if (!['start', 'stop', 'restart'].includes(action)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action. Must be one of: start, stop, restart' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify server belongs to client
|
||||
const server = await prisma.enterpriseServer.findFirst({
|
||||
where: {
|
||||
id: serverId,
|
||||
clientId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const portainerClient = await createPortainerClientForServer(serverId)
|
||||
if (!portainerClient) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Portainer not configured for this server' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'start':
|
||||
await portainerClient.startContainer(containerId)
|
||||
break
|
||||
case 'stop':
|
||||
await portainerClient.stopContainer(containerId)
|
||||
break
|
||||
case 'restart':
|
||||
await portainerClient.restartContainer(containerId)
|
||||
break
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Container ${action} successful`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${action} container:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : `Failed to ${action} container` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { createPortainerClientForServer } from '@/lib/services/portainer-client'
|
||||
|
||||
// GET /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers
|
||||
// List all containers for a server
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; serverId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, serverId } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const all = searchParams.get('all') !== 'false' // Default to showing all containers
|
||||
|
||||
// Verify server belongs to client
|
||||
const server = await prisma.enterpriseServer.findFirst({
|
||||
where: {
|
||||
id: serverId,
|
||||
clientId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if Portainer is configured
|
||||
const portainerClient = await createPortainerClientForServer(serverId)
|
||||
if (!portainerClient) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Portainer not configured for this server' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// List containers
|
||||
const containers = await portainerClient.listContainers(all)
|
||||
|
||||
// Get stats for running containers
|
||||
const stats = await portainerClient.getAllContainerStats()
|
||||
|
||||
// Combine containers with stats
|
||||
const containersWithStats = containers.map(container => ({
|
||||
id: container.Id,
|
||||
names: container.Names.map(n => n.replace(/^\//, '')), // Remove leading slash
|
||||
image: container.Image,
|
||||
imageId: container.ImageID,
|
||||
command: container.Command,
|
||||
created: container.Created,
|
||||
state: container.State,
|
||||
status: container.Status,
|
||||
ports: container.Ports,
|
||||
labels: container.Labels,
|
||||
networks: container.NetworkSettings?.Networks || {},
|
||||
stats: stats[container.Id] || null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
serverId,
|
||||
containers: containersWithStats,
|
||||
total: containersWithStats.length,
|
||||
running: containersWithStats.filter(c => c.state === 'running').length,
|
||||
stopped: containersWithStats.filter(c => c.state !== 'running').length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to list containers:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to list containers' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { enterpriseClientService } from '@/lib/services/enterprise-client-service'
|
||||
import { netcupService } from '@/lib/services/netcup-service'
|
||||
import { z } from 'zod'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string; serverId: string }>
|
||||
}
|
||||
|
||||
const updateServerSchema = z.object({
|
||||
nickname: z.string().optional().nullable(),
|
||||
purpose: z.string().optional().nullable(),
|
||||
isActive: z.boolean().optional(),
|
||||
portainerUrl: z.string().url().optional().nullable(),
|
||||
portainerUsername: z.string().optional().nullable(),
|
||||
portainerPassword: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/enterprise-clients/[id]/servers/[serverId]
|
||||
* Get server details with Netcup live info
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, serverId } = await context.params
|
||||
|
||||
try {
|
||||
const server = await enterpriseClientService.getServer(clientId, serverId)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get Netcup live info
|
||||
let netcupInfo = null
|
||||
try {
|
||||
netcupInfo = await netcupService.getServer(server.netcupServerId, true)
|
||||
} catch (error) {
|
||||
console.error('Failed to get Netcup server info:', error)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...server,
|
||||
netcup: netcupInfo
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to get server:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get server' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/admin/enterprise-clients/[id]/servers/[serverId]
|
||||
* Update server
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, serverId } = await context.params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const validation = updateServerSchema.safeParse(body)
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: validation.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if server exists and belongs to client
|
||||
const existingServer = await enterpriseClientService.getServer(clientId, serverId)
|
||||
if (!existingServer) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const server = await enterpriseClientService.updateServer(serverId, validation.data)
|
||||
return NextResponse.json(server)
|
||||
} catch (error) {
|
||||
console.error('Failed to update server:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update server' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/enterprise-clients/[id]/servers/[serverId]
|
||||
* Remove server from client (does not delete from Netcup)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, serverId } = await context.params
|
||||
|
||||
try {
|
||||
// Check if server exists and belongs to client
|
||||
const existingServer = await enterpriseClientService.getServer(clientId, serverId)
|
||||
if (!existingServer) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await enterpriseClientService.removeServer(clientId, serverId)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to remove server:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to remove server' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { enterpriseClientService } from '@/lib/services/enterprise-client-service'
|
||||
import { statsCollectionService } from '@/lib/services/stats-collection-service'
|
||||
import type { StatsRange } from '@/lib/services/stats-collection-service'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string; serverId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/stats
|
||||
* Get stats history for a server
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, serverId } = await context.params
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const range = (searchParams.get('range') || '24h') as StatsRange
|
||||
|
||||
try {
|
||||
// Verify server belongs to client
|
||||
const server = await enterpriseClientService.getServer(clientId, serverId)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [history, latest] = await Promise.all([
|
||||
statsCollectionService.getServerStatsHistory(serverId, range),
|
||||
statsCollectionService.getServerLatestStats(serverId)
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
serverId,
|
||||
range,
|
||||
latest,
|
||||
history,
|
||||
dataPoints: history.length
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to get server stats:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get server stats' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/stats
|
||||
* Trigger manual stats collection for a server
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, serverId } = await context.params
|
||||
|
||||
try {
|
||||
// Verify server belongs to client
|
||||
const server = await enterpriseClientService.getServer(clientId, serverId)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const snapshot = await statsCollectionService.collectServerStats(serverId)
|
||||
|
||||
if (!snapshot) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to collect stats' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Stats collected successfully',
|
||||
snapshot: {
|
||||
id: snapshot.id,
|
||||
timestamp: snapshot.timestamp,
|
||||
cpuPercent: snapshot.cpuPercent,
|
||||
memoryUsedMb: snapshot.memoryUsedMb,
|
||||
memoryTotalMb: snapshot.memoryTotalMb,
|
||||
diskReadMbps: snapshot.diskReadMbps,
|
||||
diskWriteMbps: snapshot.diskWriteMbps,
|
||||
networkInMbps: snapshot.networkInMbps,
|
||||
networkOutMbps: snapshot.networkOutMbps
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to collect server stats:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to collect stats' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { PortainerClient } from '@/lib/services/portainer-client'
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
id: string
|
||||
serverId: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/test-portainer
|
||||
* Test Portainer connection with provided credentials (doesn't require saved credentials)
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, serverId } = await params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { portainerUrl, portainerUsername, portainerPassword } = body
|
||||
|
||||
if (!portainerUrl || !portainerUsername || !portainerPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: portainerUrl, portainerUsername, portainerPassword' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create a temporary client with provided credentials
|
||||
const client = new PortainerClient({
|
||||
url: portainerUrl,
|
||||
username: portainerUsername,
|
||||
password: portainerPassword,
|
||||
})
|
||||
|
||||
// Test the connection
|
||||
const success = await client.testConnection()
|
||||
|
||||
if (success) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Connection successful',
|
||||
})
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: 'Connection failed - check credentials and URL',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[API] Test Portainer connection failed for server ${serverId} in client ${clientId}:`, error)
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
// Provide more specific error messages
|
||||
if (message.includes('ECONNREFUSED') || message.includes('ENOTFOUND')) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: 'Cannot connect to Portainer - check the URL is correct and the server is reachable',
|
||||
})
|
||||
}
|
||||
|
||||
if (message.includes('401') || message.includes('authentication failed')) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: 'Authentication failed - check username and password',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: `Connection test failed: ${message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { enterpriseClientService } from '@/lib/services/enterprise-client-service'
|
||||
import { securityVerificationService } from '@/lib/services/security-verification-service'
|
||||
import { z } from 'zod'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string; serverId: string }>
|
||||
}
|
||||
|
||||
const requestCodeSchema = z.object({
|
||||
action: z.enum(['WIPE', 'REINSTALL'])
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/verify
|
||||
* Request a verification code for a destructive action
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId, serverId } = await context.params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const validation = requestCodeSchema.safeParse(body)
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: validation.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if server exists and belongs to client
|
||||
const server = await enterpriseClientService.getServer(clientId, serverId)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Request verification code
|
||||
const result = await securityVerificationService.requestVerificationCode(
|
||||
clientId,
|
||||
serverId,
|
||||
validation.data.action
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Verification code sent to ${result.email}`,
|
||||
email: result.email,
|
||||
expiresAt: result.expiresAt.toISOString()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to request verification code:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to request verification code' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
137
src/app/api/v1/admin/enterprise-clients/[id]/servers/route.ts
Normal file
137
src/app/api/v1/admin/enterprise-clients/[id]/servers/route.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { enterpriseClientService } from '@/lib/services/enterprise-client-service'
|
||||
import { netcupService } from '@/lib/services/netcup-service'
|
||||
import { z } from 'zod'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
const addServerSchema = z.object({
|
||||
netcupServerId: z.string().min(1, 'Netcup server ID is required'),
|
||||
nickname: z.string().optional(),
|
||||
purpose: z.string().optional(),
|
||||
portainerUrl: z.string().url().optional(),
|
||||
portainerUsername: z.string().optional(),
|
||||
portainerPassword: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/enterprise-clients/[id]/servers
|
||||
* List all servers for an enterprise client with live status from Netcup
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId } = await context.params
|
||||
|
||||
try {
|
||||
// Get client's servers from database
|
||||
const servers = await enterpriseClientService.getClientServers(clientId)
|
||||
|
||||
// Enrich with live Netcup status if possible
|
||||
const enrichedServers = await Promise.all(
|
||||
servers.map(async (server) => {
|
||||
try {
|
||||
const netcupServer = await netcupService.getServer(server.netcupServerId, true)
|
||||
return {
|
||||
...server,
|
||||
netcupStatus: netcupServer?.state || 'unknown',
|
||||
netcupHostname: netcupServer?.hostname,
|
||||
netcupIps: [netcupServer?.primaryIpv4, netcupServer?.primaryIpv6].filter(Boolean) as string[]
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
...server,
|
||||
netcupStatus: 'error',
|
||||
netcupHostname: null,
|
||||
netcupIps: []
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json(enrichedServers)
|
||||
} catch (error) {
|
||||
console.error('Failed to list servers:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list servers' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/enterprise-clients/[id]/servers
|
||||
* Add a server to an enterprise client
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId } = await context.params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const validation = addServerSchema.safeParse(body)
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: validation.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify client exists
|
||||
const client = await enterpriseClientService.getClient(clientId)
|
||||
if (!client) {
|
||||
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify Netcup server exists
|
||||
try {
|
||||
const netcupServer = await netcupService.getServer(validation.data.netcupServerId)
|
||||
if (!netcupServer) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Netcup server not found' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to verify Netcup server. Is Netcup connected?' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const server = await enterpriseClientService.addServer(clientId, validation.data)
|
||||
return NextResponse.json(server, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Failed to add server:', error)
|
||||
|
||||
// Check for unique constraint violation
|
||||
if (error instanceof Error && error.message.includes('Unique constraint')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This Netcup server is already linked to this client' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to add server' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
40
src/app/api/v1/admin/enterprise-clients/[id]/stats/route.ts
Normal file
40
src/app/api/v1/admin/enterprise-clients/[id]/stats/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { statsCollectionService } from '@/lib/services/stats-collection-service'
|
||||
import type { StatsRange } from '@/lib/services/stats-collection-service'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/enterprise-clients/[id]/stats
|
||||
* Get aggregated stats overview for an enterprise client
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: clientId } = await context.params
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const range = (searchParams.get('range') || '24h') as StatsRange
|
||||
|
||||
try {
|
||||
const overview = await statsCollectionService.getClientStatsOverview(clientId)
|
||||
return NextResponse.json({
|
||||
...overview,
|
||||
range
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to get client stats:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get client stats' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { errorDashboardService } from '@/lib/services/error-dashboard-service'
|
||||
|
||||
// GET /api/v1/admin/enterprise-clients/error-summary
|
||||
// Get error summary for ALL clients (for main enterprise page widget)
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const summaries = await errorDashboardService.getAllClientsErrorSummary()
|
||||
|
||||
// Calculate totals
|
||||
const totals = summaries.reduce(
|
||||
(acc, client) => ({
|
||||
criticalErrors24h: acc.criticalErrors24h + client.criticalErrors24h,
|
||||
totalErrors24h: acc.totalErrors24h + client.totalErrors24h,
|
||||
crashes24h: acc.crashes24h + client.crashes24h,
|
||||
clientsWithIssues: acc.clientsWithIssues + (client.criticalErrors24h > 0 || client.crashes24h > 0 ? 1 : 0),
|
||||
}),
|
||||
{ criticalErrors24h: 0, totalErrors24h: 0, crashes24h: 0, clientsWithIssues: 0 }
|
||||
)
|
||||
|
||||
// Calculate overall trend
|
||||
const increasingCount = summaries.filter(s => s.errorTrend === 'increasing').length
|
||||
const decreasingCount = summaries.filter(s => s.errorTrend === 'decreasing').length
|
||||
let overallTrend: 'increasing' | 'decreasing' | 'stable' = 'stable'
|
||||
if (increasingCount > decreasingCount) {
|
||||
overallTrend = 'increasing'
|
||||
} else if (decreasingCount > increasingCount) {
|
||||
overallTrend = 'decreasing'
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
clients: summaries,
|
||||
totals: {
|
||||
...totals,
|
||||
overallTrend,
|
||||
totalClients: summaries.length,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to get error summary:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get error summary' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
66
src/app/api/v1/admin/enterprise-clients/route.ts
Normal file
66
src/app/api/v1/admin/enterprise-clients/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { enterpriseClientService } from '@/lib/services/enterprise-client-service'
|
||||
import { z } from 'zod'
|
||||
|
||||
const createClientSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
companyName: z.string().optional(),
|
||||
contactEmail: z.string().email('Valid email is required'),
|
||||
contactPhone: z.string().optional(),
|
||||
notes: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/enterprise-clients
|
||||
* List all enterprise clients
|
||||
*/
|
||||
export async function GET() {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const clients = await enterpriseClientService.getClients()
|
||||
return NextResponse.json(clients)
|
||||
} catch (error) {
|
||||
console.error('Failed to list enterprise clients:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list enterprise clients' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/enterprise-clients
|
||||
* Create a new enterprise client
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const validation = createClientSchema.safeParse(body)
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: validation.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const client = await enterpriseClientService.createClient(validation.data)
|
||||
return NextResponse.json(client, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Failed to create enterprise client:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create enterprise client' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
185
src/app/api/v1/admin/netcup/auth/route.ts
Normal file
185
src/app/api/v1/admin/netcup/auth/route.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { netcupService, NetcupAuthError } from '@/lib/services/netcup-service'
|
||||
|
||||
// Store pending device auth sessions (in-memory for simplicity)
|
||||
// In production, consider storing in Redis or database
|
||||
const pendingAuthSessions = new Map<
|
||||
string,
|
||||
{
|
||||
deviceCode: string
|
||||
expiresAt: number
|
||||
interval: number
|
||||
}
|
||||
>()
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/netcup/auth
|
||||
* Get current authentication status
|
||||
*/
|
||||
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 status = await netcupService.getAuthStatus()
|
||||
|
||||
return NextResponse.json(status)
|
||||
} catch (error) {
|
||||
console.error('Error getting Netcup auth status:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get auth status' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/netcup/auth
|
||||
* Initiate device auth flow or poll for token
|
||||
*
|
||||
* Body:
|
||||
* - action: 'initiate' | 'poll' | 'disconnect'
|
||||
* - sessionId?: string (for poll action)
|
||||
*/
|
||||
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 { action, sessionId } = body as {
|
||||
action: 'initiate' | 'poll' | 'disconnect'
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
if (!action || !['initiate', 'poll', 'disconnect'].includes(action)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action. Must be: initiate, poll, or disconnect' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'initiate': {
|
||||
// Start device auth flow
|
||||
const deviceAuth = await netcupService.initiateDeviceAuth()
|
||||
|
||||
// Store session for polling
|
||||
const newSessionId = crypto.randomUUID()
|
||||
pendingAuthSessions.set(newSessionId, {
|
||||
deviceCode: deviceAuth.device_code,
|
||||
expiresAt: Date.now() + deviceAuth.expires_in * 1000,
|
||||
interval: deviceAuth.interval,
|
||||
})
|
||||
|
||||
// Clean up expired sessions
|
||||
for (const [id, sess] of pendingAuthSessions) {
|
||||
if (sess.expiresAt < Date.now()) {
|
||||
pendingAuthSessions.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sessionId: newSessionId,
|
||||
userCode: deviceAuth.user_code,
|
||||
verificationUri: deviceAuth.verification_uri,
|
||||
verificationUriComplete: deviceAuth.verification_uri_complete,
|
||||
expiresIn: deviceAuth.expires_in,
|
||||
interval: deviceAuth.interval,
|
||||
})
|
||||
}
|
||||
|
||||
case 'poll': {
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Session ID required for polling' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const pendingSession = pendingAuthSessions.get(sessionId)
|
||||
|
||||
if (!pendingSession) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Session not found or expired' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (pendingSession.expiresAt < Date.now()) {
|
||||
pendingAuthSessions.delete(sessionId)
|
||||
return NextResponse.json(
|
||||
{ error: 'Session expired' },
|
||||
{ status: 410 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await netcupService.pollForToken(pendingSession.deviceCode)
|
||||
|
||||
if (!tokens) {
|
||||
// Still waiting for user authorization
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
status: 'pending',
|
||||
message: 'Waiting for user authorization',
|
||||
})
|
||||
}
|
||||
|
||||
// Success! Clean up session
|
||||
pendingAuthSessions.delete(sessionId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
status: 'authenticated',
|
||||
message: 'Successfully authenticated with Netcup',
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof NetcupAuthError) {
|
||||
pendingAuthSessions.delete(sessionId)
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
case 'disconnect': {
|
||||
// Clear stored tokens
|
||||
await netcupService.clearTokens()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Disconnected from Netcup',
|
||||
})
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in Netcup auth:', error)
|
||||
|
||||
if (error instanceof NetcupAuthError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process auth request' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
82
src/app/api/v1/admin/netcup/servers/[id]/metrics/route.ts
Normal file
82
src/app/api/v1/admin/netcup/servers/[id]/metrics/route.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import {
|
||||
netcupService,
|
||||
NetcupAuthError,
|
||||
NetcupApiError,
|
||||
} from '@/lib/services/netcup-service'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/netcup/servers/[id]/metrics
|
||||
* Get server metrics (CPU, disk, network)
|
||||
*
|
||||
* Query params:
|
||||
* - hours: Number of hours of history (default: 24, max: 1440)
|
||||
* - type: 'all' | 'cpu' | 'disk' | 'network' (default: 'all')
|
||||
*/
|
||||
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: serverId } = await params
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const hours = Math.min(parseInt(searchParams.get('hours') || '24', 10), 1440)
|
||||
const type = searchParams.get('type') || 'all'
|
||||
|
||||
// Check if authenticated with Netcup
|
||||
const isAuth = await netcupService.isAuthenticated()
|
||||
if (!isAuth) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not authenticated with Netcup' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'cpu': {
|
||||
const cpu = await netcupService.getCpuMetrics(serverId, hours)
|
||||
console.log('CPU metrics response:', JSON.stringify(cpu, null, 2))
|
||||
return NextResponse.json({ cpu, period: `${hours}h` })
|
||||
}
|
||||
case 'disk': {
|
||||
const disk = await netcupService.getDiskMetrics(serverId, hours)
|
||||
return NextResponse.json({ disk, period: `${hours}h` })
|
||||
}
|
||||
case 'network': {
|
||||
const network = await netcupService.getNetworkMetrics(serverId, hours)
|
||||
return NextResponse.json({ network, period: `${hours}h` })
|
||||
}
|
||||
case 'all':
|
||||
default: {
|
||||
const metrics = await netcupService.getAllMetrics(serverId, hours)
|
||||
console.log('All metrics response:', JSON.stringify(metrics, null, 2))
|
||||
return NextResponse.json(metrics)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting server metrics:', error)
|
||||
|
||||
if (error instanceof NetcupAuthError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 401 })
|
||||
}
|
||||
|
||||
if (error instanceof NetcupApiError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: error.statusCode }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get server metrics' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
221
src/app/api/v1/admin/netcup/servers/[id]/route.ts
Normal file
221
src/app/api/v1/admin/netcup/servers/[id]/route.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import {
|
||||
netcupService,
|
||||
NetcupAuthError,
|
||||
NetcupApiError,
|
||||
PowerAction,
|
||||
} from '@/lib/services/netcup-service'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/netcup/servers/[id]
|
||||
* Get server details
|
||||
*
|
||||
* Query params:
|
||||
* - liveInfo=true: Include live CPU/RAM/disk usage
|
||||
*/
|
||||
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: serverId } = await params
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const loadLiveInfo = searchParams.get('liveInfo') === 'true'
|
||||
|
||||
const server = await netcupService.getServer(serverId, loadLiveInfo)
|
||||
|
||||
return NextResponse.json(server)
|
||||
} catch (error) {
|
||||
console.error('Error getting Netcup server:', error)
|
||||
|
||||
if (error instanceof NetcupAuthError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 401 })
|
||||
}
|
||||
|
||||
if (error instanceof NetcupApiError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: error.statusCode }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get server' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/netcup/servers/[id]
|
||||
* Perform action on server
|
||||
*
|
||||
* Body:
|
||||
* - action: 'power' | 'reinstall' | 'rescue' | 'hostname' | 'nickname'
|
||||
* - powerAction?: 'ON' | 'OFF' | 'POWERCYCLE' | 'RESET' | 'POWEROFF'
|
||||
* - imageFlavour?: string (for reinstall)
|
||||
* - rescueAction?: 'activate' | 'deactivate' (for rescue)
|
||||
* - hostname?: string (for hostname update)
|
||||
* - nickname?: string (for nickname update)
|
||||
*/
|
||||
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: serverId } = await params
|
||||
const body = await request.json()
|
||||
|
||||
const { action, powerAction, imageFlavour, rescueAction, hostname, nickname } = body as {
|
||||
action: 'power' | 'reinstall' | 'rescue' | 'hostname' | 'nickname' | 'imageFlavours'
|
||||
powerAction?: PowerAction
|
||||
imageFlavour?: string
|
||||
rescueAction?: 'activate' | 'deactivate'
|
||||
hostname?: string
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
if (!action) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Action required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'power': {
|
||||
if (!powerAction || !['ON', 'OFF', 'POWERCYCLE', 'RESET', 'POWEROFF'].includes(powerAction)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Valid powerAction required: ON, OFF, POWERCYCLE, RESET, POWEROFF' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await netcupService.powerAction(serverId, powerAction)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Power action ${powerAction} executed`,
|
||||
})
|
||||
}
|
||||
|
||||
case 'reinstall': {
|
||||
if (!imageFlavour) {
|
||||
return NextResponse.json(
|
||||
{ error: 'imageFlavour required for reinstall' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const task = await netcupService.reinstallServer(serverId, imageFlavour)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Reinstall started',
|
||||
taskId: task.taskId,
|
||||
})
|
||||
}
|
||||
|
||||
case 'rescue': {
|
||||
if (!rescueAction || !['activate', 'deactivate'].includes(rescueAction)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'rescueAction required: activate or deactivate' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (rescueAction === 'activate') {
|
||||
const credentials = await netcupService.activateRescue(serverId)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Rescue mode activated',
|
||||
credentials,
|
||||
})
|
||||
} else {
|
||||
await netcupService.deactivateRescue(serverId)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Rescue mode deactivated',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
case 'hostname': {
|
||||
if (!hostname) {
|
||||
return NextResponse.json(
|
||||
{ error: 'hostname required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await netcupService.updateHostname(serverId, hostname)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Hostname updated',
|
||||
})
|
||||
}
|
||||
|
||||
case 'nickname': {
|
||||
if (nickname === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: 'nickname required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await netcupService.updateNickname(serverId, nickname)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Nickname updated',
|
||||
})
|
||||
}
|
||||
|
||||
case 'imageFlavours': {
|
||||
const flavours = await netcupService.getImageFlavours(serverId)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
flavours,
|
||||
})
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error performing Netcup server action:', error)
|
||||
|
||||
if (error instanceof NetcupAuthError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 401 })
|
||||
}
|
||||
|
||||
if (error instanceof NetcupApiError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: error.statusCode }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to perform server action' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
176
src/app/api/v1/admin/netcup/servers/[id]/snapshots/route.ts
Normal file
176
src/app/api/v1/admin/netcup/servers/[id]/snapshots/route.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import {
|
||||
netcupService,
|
||||
NetcupAuthError,
|
||||
NetcupApiError,
|
||||
} from '@/lib/services/netcup-service'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/netcup/servers/[id]/snapshots
|
||||
* Get list of server snapshots
|
||||
*/
|
||||
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: serverId } = await params
|
||||
|
||||
// Check if authenticated with Netcup
|
||||
const isAuth = await netcupService.isAuthenticated()
|
||||
if (!isAuth) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not authenticated with Netcup' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const snapshots = await netcupService.getSnapshots(serverId)
|
||||
|
||||
return NextResponse.json({
|
||||
snapshots,
|
||||
count: snapshots.length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error getting server snapshots:', error)
|
||||
|
||||
if (error instanceof NetcupAuthError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 401 })
|
||||
}
|
||||
|
||||
if (error instanceof NetcupApiError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: error.statusCode }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get server snapshots' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/netcup/servers/[id]/snapshots
|
||||
* Create a new snapshot or perform snapshot actions
|
||||
*
|
||||
* Body:
|
||||
* - action: 'create' | 'delete' | 'revert' | 'check'
|
||||
* - name?: string (snapshot name for create/delete/revert)
|
||||
*/
|
||||
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: serverId } = await params
|
||||
const body = await request.json()
|
||||
|
||||
const { action, name } = body as {
|
||||
action: 'create' | 'delete' | 'revert' | 'check'
|
||||
name?: string
|
||||
}
|
||||
|
||||
if (!action) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Action required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if authenticated with Netcup
|
||||
const isAuth = await netcupService.isAuthenticated()
|
||||
if (!isAuth) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not authenticated with Netcup' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'check': {
|
||||
const result = await netcupService.canCreateSnapshot(serverId)
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
|
||||
case 'create': {
|
||||
const result = await netcupService.createSnapshot(serverId, name)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Snapshot created: ${result.name}`,
|
||||
snapshot: result,
|
||||
})
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
if (!name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Snapshot name required for delete' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await netcupService.deleteSnapshot(serverId, name)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Snapshot deleted: ${name}`,
|
||||
})
|
||||
}
|
||||
|
||||
case 'revert': {
|
||||
if (!name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Snapshot name required for revert' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const task = await netcupService.revertSnapshot(serverId, name)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Reverting to snapshot: ${name}`,
|
||||
taskId: task.taskId,
|
||||
})
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action. Use: create, delete, revert, or check' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error performing snapshot action:', error)
|
||||
|
||||
if (error instanceof NetcupAuthError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 401 })
|
||||
}
|
||||
|
||||
if (error instanceof NetcupApiError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: error.statusCode }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to perform snapshot action' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
61
src/app/api/v1/admin/netcup/servers/route.ts
Normal file
61
src/app/api/v1/admin/netcup/servers/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { netcupService, NetcupAuthError, NetcupApiError } from '@/lib/services/netcup-service'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/netcup/servers
|
||||
* Get list of all Netcup servers
|
||||
*
|
||||
* Query params:
|
||||
* - liveInfo: "true" to load live status (ON/OFF) for each server (slower)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if authenticated
|
||||
const isAuth = await netcupService.isAuthenticated()
|
||||
if (!isAuth) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not authenticated with Netcup. Please connect your account first.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check for liveInfo query parameter
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const loadLiveInfo = searchParams.get('liveInfo') === 'true'
|
||||
|
||||
const servers = await netcupService.getServers(loadLiveInfo)
|
||||
|
||||
return NextResponse.json({
|
||||
servers,
|
||||
count: servers.length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error getting Netcup servers:', error)
|
||||
|
||||
if (error instanceof NetcupAuthError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
if (error instanceof NetcupApiError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: error.statusCode }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get servers' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
40
src/app/api/v1/admin/netcup/tasks/[id]/route.ts
Normal file
40
src/app/api/v1/admin/netcup/tasks/[id]/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { netcupService } from '@/lib/services/netcup-service'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/netcup/tasks/[id]
|
||||
* Get task status for polling during long-running operations (reinstall, etc.)
|
||||
*/
|
||||
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: taskId } = await params
|
||||
|
||||
const task = await netcupService.getTask(taskId)
|
||||
|
||||
return NextResponse.json(task)
|
||||
} catch (error) {
|
||||
console.error('Error getting task status:', error)
|
||||
|
||||
// Check if it's a 404 (task not found or expired)
|
||||
if (error instanceof Error && error.message.includes('404')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Task not found', taskId: (await params).id, status: 'UNKNOWN' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get task status' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
216
src/app/api/v1/admin/orders/[id]/automation/route.ts
Normal file
216
src/app/api/v1/admin/orders/[id]/automation/route.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { AutomationMode } from '@prisma/client'
|
||||
import {
|
||||
processAutomation,
|
||||
setAutomationMode,
|
||||
resumeAutomation,
|
||||
pauseAutomation,
|
||||
takeManualControl,
|
||||
enableAutoMode,
|
||||
} from '@/lib/services/automation-worker'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/orders/[id]/automation
|
||||
* Get current automation status
|
||||
*/
|
||||
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 },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
automationMode: true,
|
||||
automationPausedAt: true,
|
||||
automationPausedReason: true,
|
||||
source: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
mode: order.automationMode,
|
||||
pausedAt: order.automationPausedAt,
|
||||
pausedReason: order.automationPausedReason,
|
||||
source: order.source,
|
||||
status: order.status,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error getting automation status:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get automation status' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/admin/orders/[id]/automation
|
||||
* Change automation mode
|
||||
*
|
||||
* Body:
|
||||
* - action: 'auto' | 'manual' | 'pause' | 'resume'
|
||||
* - reason?: string (optional reason for pause)
|
||||
*/
|
||||
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()
|
||||
|
||||
const { action, reason } = body as { action: string; reason?: string }
|
||||
|
||||
if (!action || !['auto', 'manual', 'pause', 'resume'].includes(action)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action. Must be: auto, manual, pause, or resume' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check order exists
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let result
|
||||
switch (action) {
|
||||
case 'auto':
|
||||
result = await enableAutoMode(orderId)
|
||||
break
|
||||
|
||||
case 'manual':
|
||||
await takeManualControl(orderId)
|
||||
result = { triggered: false, action: 'Switched to manual mode' }
|
||||
break
|
||||
|
||||
case 'pause':
|
||||
await pauseAutomation(orderId, reason)
|
||||
result = { triggered: false, action: 'Automation paused' }
|
||||
break
|
||||
|
||||
case 'resume':
|
||||
result = await resumeAutomation(orderId)
|
||||
break
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get updated order
|
||||
const updatedOrder = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
automationMode: true,
|
||||
automationPausedAt: true,
|
||||
automationPausedReason: true,
|
||||
source: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result,
|
||||
automation: {
|
||||
mode: updatedOrder?.automationMode,
|
||||
pausedAt: updatedOrder?.automationPausedAt,
|
||||
pausedReason: updatedOrder?.automationPausedReason,
|
||||
source: updatedOrder?.source,
|
||||
status: updatedOrder?.status,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating automation:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update automation' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/orders/[id]/automation
|
||||
* Trigger automation processing (useful for manual refresh)
|
||||
*/
|
||||
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 order exists
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Process automation
|
||||
const result = await processAutomation(orderId)
|
||||
|
||||
// Get updated order
|
||||
const updatedOrder = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
automationMode: true,
|
||||
automationPausedAt: true,
|
||||
automationPausedReason: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result,
|
||||
automation: {
|
||||
mode: updatedOrder?.automationMode,
|
||||
status: updatedOrder?.status,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error processing automation:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process automation' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { createPortainerClient } from '@/lib/services/portainer-client'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string; containerId: string; action: string }>
|
||||
}
|
||||
|
||||
const ALLOWED_ACTIONS = ['start', 'stop', 'restart'] as const
|
||||
type ContainerAction = (typeof ALLOWED_ACTIONS)[number]
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/orders/[id]/containers/[containerId]/[action]
|
||||
* Perform a container action (start, stop, restart)
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId, containerId, action } = await context.params
|
||||
|
||||
// Validate action
|
||||
if (!ALLOWED_ACTIONS.includes(action as ContainerAction)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid action: ${action}. Allowed actions: ${ALLOWED_ACTIONS.join(', ')}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await createPortainerClient(orderId)
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Portainer credentials not configured for this order' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Execute the action
|
||||
switch (action as ContainerAction) {
|
||||
case 'start':
|
||||
await client.startContainer(containerId)
|
||||
break
|
||||
case 'stop':
|
||||
await client.stopContainer(containerId)
|
||||
break
|
||||
case 'restart':
|
||||
await client.restartContainer(containerId)
|
||||
break
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, action })
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${action} container:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : `Failed to ${action} container` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { createPortainerClient } from '@/lib/services/portainer-client'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string; containerId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/orders/[id]/containers/[containerId]/logs
|
||||
* Get container logs
|
||||
* Query: ?tail=100 (number of lines)
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId, containerId } = await context.params
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const tail = parseInt(searchParams.get('tail') || '100', 10)
|
||||
|
||||
try {
|
||||
const client = await createPortainerClient(orderId)
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Portainer credentials not configured for this order' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const logs = await client.getContainerLogs(containerId, tail)
|
||||
|
||||
return NextResponse.json({ logs })
|
||||
} catch (error) {
|
||||
console.error('Failed to get container logs:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to get container logs' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { createPortainerClient } from '@/lib/services/portainer-client'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string; containerId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/orders/[id]/containers/[containerId]
|
||||
* Get container details
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId, containerId } = await context.params
|
||||
|
||||
try {
|
||||
const client = await createPortainerClient(orderId)
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Portainer credentials not configured for this order' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const container = await client.getContainer(containerId)
|
||||
|
||||
// Parse ports from NetworkSettings.Ports (inspect format)
|
||||
const ports: Array<{ private: number; public?: number; type: string }> = []
|
||||
if (container.NetworkSettings.Ports) {
|
||||
for (const [portKey, bindings] of Object.entries(container.NetworkSettings.Ports)) {
|
||||
// portKey is like "80/tcp" or "443/tcp"
|
||||
const [port, type] = portKey.split('/')
|
||||
const privatePort = parseInt(port, 10)
|
||||
|
||||
if (bindings && bindings.length > 0) {
|
||||
// Has host bindings
|
||||
for (const binding of bindings) {
|
||||
ports.push({
|
||||
private: privatePort,
|
||||
public: binding.HostPort ? parseInt(binding.HostPort, 10) : undefined,
|
||||
type: type || 'tcp',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Exposed but not published
|
||||
ports.push({
|
||||
private: privatePort,
|
||||
type: type || 'tcp',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transform to a more readable format
|
||||
const formattedContainer = {
|
||||
id: container.Id,
|
||||
shortId: container.Id.substring(0, 12),
|
||||
name: container.Name?.replace(/^\//, '') || container.Id.substring(0, 12),
|
||||
image: container.Image,
|
||||
state: container.State.Status, // State is an object in inspect response
|
||||
status: container.State.Running ? 'running' : container.State.Status,
|
||||
created: container.Created,
|
||||
config: {
|
||||
hostname: container.Config.Hostname,
|
||||
image: container.Config.Image,
|
||||
workingDir: container.Config.WorkingDir,
|
||||
env: container.Config.Env || [],
|
||||
},
|
||||
hostConfig: {
|
||||
restartPolicy: container.HostConfig.RestartPolicy,
|
||||
},
|
||||
ports,
|
||||
networks: container.NetworkSettings.Networks,
|
||||
}
|
||||
|
||||
return NextResponse.json(formattedContainer)
|
||||
} catch (error) {
|
||||
console.error('Failed to get container details:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to get container details' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/orders/[id]/containers/[containerId]
|
||||
* Remove a container
|
||||
* Query: ?force=true (force remove running container)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId, containerId } = await context.params
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const force = searchParams.get('force') === 'true'
|
||||
|
||||
try {
|
||||
const client = await createPortainerClient(orderId)
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Portainer credentials not configured for this order' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await client.removeContainer(containerId, force)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to remove container:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to remove container' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { createPortainerClient } from '@/lib/services/portainer-client'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string; containerId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/orders/[id]/containers/[containerId]/stats
|
||||
* Get stats for a single container
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId, containerId } = await context.params
|
||||
|
||||
try {
|
||||
const client = await createPortainerClient(orderId)
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Portainer credentials not configured for this order' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const stats = await client.getContainerStats(containerId)
|
||||
|
||||
if (!stats) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Container not running or stats unavailable' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(stats)
|
||||
} catch (error) {
|
||||
console.error('Failed to get container stats:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to get container stats' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
63
src/app/api/v1/admin/orders/[id]/containers/route.ts
Normal file
63
src/app/api/v1/admin/orders/[id]/containers/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { createPortainerClient } from '@/lib/services/portainer-client'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/orders/[id]/containers
|
||||
* List all containers for an order's Portainer instance
|
||||
* Query: ?all=true (include stopped containers)
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId } = await context.params
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const all = searchParams.get('all') !== 'false' // Default to true
|
||||
|
||||
try {
|
||||
const client = await createPortainerClient(orderId)
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Portainer credentials not configured for this order' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const containers = await client.listContainers(all)
|
||||
|
||||
// Transform to a simpler format for the frontend
|
||||
const formattedContainers = containers.map((c) => ({
|
||||
id: c.Id,
|
||||
shortId: c.Id.substring(0, 12),
|
||||
name: c.Names[0]?.replace(/^\//, '') || c.Id.substring(0, 12),
|
||||
image: c.Image,
|
||||
state: c.State,
|
||||
status: c.Status,
|
||||
created: c.Created,
|
||||
ports: c.Ports.map((p) => ({
|
||||
private: p.PrivatePort,
|
||||
public: p.PublicPort,
|
||||
type: p.Type,
|
||||
})),
|
||||
}))
|
||||
|
||||
return NextResponse.json(formattedContainers)
|
||||
} catch (error) {
|
||||
console.error('Failed to list containers:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to list containers' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
44
src/app/api/v1/admin/orders/[id]/containers/stats/route.ts
Normal file
44
src/app/api/v1/admin/orders/[id]/containers/stats/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { createPortainerClient } from '@/lib/services/portainer-client'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/orders/[id]/containers/stats
|
||||
* Get stats for all running containers
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId } = await context.params
|
||||
|
||||
try {
|
||||
const client = await createPortainerClient(orderId)
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Portainer credentials not configured for this order' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const stats = await client.getAllContainerStats()
|
||||
|
||||
return NextResponse.json(stats)
|
||||
} catch (error) {
|
||||
console.error('Failed to get container stats:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to get container stats' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
37
src/app/api/v1/admin/orders/[id]/dns/route.ts
Normal file
37
src/app/api/v1/admin/orders/[id]/dns/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { getDnsStatus } from '@/lib/services/dns-service'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/orders/[id]/dns
|
||||
* Get DNS verification status for an order
|
||||
*/
|
||||
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 status = await getDnsStatus(orderId)
|
||||
|
||||
return NextResponse.json(status)
|
||||
} catch (error) {
|
||||
console.error('Error getting DNS status:', error)
|
||||
|
||||
if (error instanceof Error && error.message === 'Order not found') {
|
||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get DNS status' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
53
src/app/api/v1/admin/orders/[id]/dns/skip/route.ts
Normal file
53
src/app/api/v1/admin/orders/[id]/dns/skip/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { skipDnsVerification, getDnsStatus } from '@/lib/services/dns-service'
|
||||
import { processAutomation } from '@/lib/services/automation-worker'
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/orders/[id]/dns/skip
|
||||
* Skip DNS verification with manual override (staff only)
|
||||
*
|
||||
* This allows staff to bypass DNS verification when they know
|
||||
* DNS is configured correctly but checks are failing (e.g., DNS propagation delay).
|
||||
*/
|
||||
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
|
||||
|
||||
// Skip DNS verification
|
||||
await skipDnsVerification(orderId)
|
||||
|
||||
// Get updated status
|
||||
const status = await getDnsStatus(orderId)
|
||||
|
||||
// Process automation (will auto-trigger next step if in AUTO mode)
|
||||
const automationResult = await processAutomation(orderId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'DNS verification skipped via manual override',
|
||||
status,
|
||||
automation: automationResult,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error skipping DNS verification:', error)
|
||||
|
||||
if (error instanceof Error && error.message === 'Order not found') {
|
||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to skip DNS verification' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
54
src/app/api/v1/admin/orders/[id]/dns/verify/route.ts
Normal file
54
src/app/api/v1/admin/orders/[id]/dns/verify/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { runDnsVerification } from '@/lib/services/dns-service'
|
||||
import { processAutomation } from '@/lib/services/automation-worker'
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/orders/[id]/dns/verify
|
||||
* Trigger DNS verification 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
|
||||
|
||||
// Run DNS verification
|
||||
const result = await runDnsVerification(orderId)
|
||||
|
||||
// Process automation (will auto-trigger next step if in AUTO mode)
|
||||
const automationResult = await processAutomation(orderId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
verification: result,
|
||||
automation: automationResult,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error verifying DNS:', error)
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Order not found') {
|
||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||
}
|
||||
if (error.message === 'Server IP not configured') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server IP not configured. Please add server credentials first.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to verify DNS' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,17 @@ import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { OrderStatus } from '@prisma/client'
|
||||
|
||||
// Polling interval in milliseconds - faster = smoother logs
|
||||
const POLL_INTERVAL_MS = 500
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/orders/[id]/logs/stream
|
||||
* Stream provisioning logs via Server-Sent Events
|
||||
*
|
||||
* Optimized for low-latency log streaming:
|
||||
* - 500ms polling interval for near-real-time updates
|
||||
* - Only sends status updates when changed
|
||||
* - Batches multiple logs per event for efficiency
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -33,6 +41,7 @@ export async function GET(
|
||||
// Create SSE response
|
||||
const encoder = new TextEncoder()
|
||||
let lastLogId: string | null = null
|
||||
let lastStatus: OrderStatus = order.status
|
||||
let isActive = true
|
||||
|
||||
const stream = new ReadableStream({
|
||||
@@ -42,16 +51,31 @@ export async function GET(
|
||||
encoder.encode(`event: connected\ndata: ${JSON.stringify({ orderId })}\n\n`)
|
||||
)
|
||||
|
||||
// Send initial status
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: status\ndata: ${JSON.stringify({ status: order.status })}\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 },
|
||||
})
|
||||
// Fetch new logs and current status in parallel for speed
|
||||
const [newLogs, currentOrder] = await Promise.all([
|
||||
prisma.provisioningLog.findMany({
|
||||
where: {
|
||||
orderId,
|
||||
...(lastLogId ? { id: { gt: lastLogId } } : {}),
|
||||
},
|
||||
orderBy: { timestamp: 'asc' },
|
||||
take: 100, // Increased batch size
|
||||
}),
|
||||
prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: { status: true },
|
||||
}),
|
||||
])
|
||||
|
||||
if (!currentOrder) {
|
||||
controller.enqueue(
|
||||
@@ -61,23 +85,11 @@ export async function GET(
|
||||
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)
|
||||
|
||||
// Send logs immediately as they arrive
|
||||
if (newLogs.length > 0) {
|
||||
// Update last seen log ID
|
||||
lastLogId = newLogs[newLogs.length - 1].id
|
||||
|
||||
// Send each log as an event
|
||||
// Send each log individually for smooth streaming
|
||||
for (const log of newLogs) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: log\ndata: ${JSON.stringify({
|
||||
@@ -91,10 +103,13 @@ export async function GET(
|
||||
}
|
||||
}
|
||||
|
||||
// Send status update
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: status\ndata: ${JSON.stringify({ status: currentOrder.status })}\n\n`)
|
||||
)
|
||||
// Only send status update if changed
|
||||
if (currentOrder.status !== lastStatus) {
|
||||
lastStatus = currentOrder.status
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: status\ndata: ${JSON.stringify({ status: currentOrder.status })}\n\n`)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if provisioning is complete
|
||||
const terminalStatuses: OrderStatus[] = [
|
||||
@@ -114,8 +129,8 @@ export async function GET(
|
||||
return
|
||||
}
|
||||
|
||||
// Continue polling if still provisioning
|
||||
setTimeout(poll, 2000) // Poll every 2 seconds
|
||||
// Continue polling with fast interval
|
||||
setTimeout(poll, POLL_INTERVAL_MS)
|
||||
} catch (err) {
|
||||
console.error('SSE polling error:', err)
|
||||
controller.enqueue(
|
||||
@@ -125,7 +140,7 @@ export async function GET(
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling
|
||||
// Start polling immediately
|
||||
poll()
|
||||
},
|
||||
cancel() {
|
||||
|
||||
135
src/app/api/v1/admin/orders/[id]/portainer/init/route.ts
Normal file
135
src/app/api/v1/admin/orders/[id]/portainer/init/route.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { credentialService } from '@/lib/services/credential-service'
|
||||
import { Agent, fetch as undiciFetch, FormData as UndiciFormData } from 'undici'
|
||||
|
||||
// Create undici agent that accepts self-signed certificates
|
||||
const insecureAgent = new Agent({
|
||||
connect: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/orders/[id]/portainer/init
|
||||
* Initialize Portainer by creating the local Docker endpoint
|
||||
* This is needed when Portainer is set up with --admin-password-file (skips wizard)
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
// Auth check
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId } = await params
|
||||
|
||||
// Get order with Portainer credentials
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: {
|
||||
serverIp: true,
|
||||
portainerUsername: true,
|
||||
portainerPasswordEnc: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!order.serverIp || !order.portainerUsername || !order.portainerPasswordEnc) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Portainer credentials not configured' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const portainerUrl = `https://${order.serverIp}:9443`
|
||||
const password = credentialService.decrypt(order.portainerPasswordEnc)
|
||||
|
||||
try {
|
||||
// Step 1: Authenticate with Portainer
|
||||
const authResponse = await undiciFetch(`${portainerUrl}/api/auth`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: order.portainerUsername,
|
||||
password: password,
|
||||
}),
|
||||
dispatcher: insecureAgent,
|
||||
})
|
||||
|
||||
if (!authResponse.ok) {
|
||||
const error = await authResponse.text()
|
||||
return NextResponse.json(
|
||||
{ error: `Authentication failed: ${error}` },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const authData = await authResponse.json() as { jwt: string }
|
||||
const jwt = authData.jwt
|
||||
|
||||
// Step 2: Check if endpoint already exists
|
||||
const endpointsResponse = await undiciFetch(`${portainerUrl}/api/endpoints`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
dispatcher: insecureAgent,
|
||||
})
|
||||
|
||||
if (endpointsResponse.ok) {
|
||||
const endpoints = await endpointsResponse.json() as Array<{ Id: number; Name: string }>
|
||||
if (endpoints.length > 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Endpoint already exists',
|
||||
endpoint: endpoints[0],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Create local Docker socket endpoint
|
||||
const formData = new UndiciFormData()
|
||||
formData.append('Name', 'local')
|
||||
formData.append('EndpointCreationType', '1') // 1 = Docker socket
|
||||
|
||||
const createResponse = await undiciFetch(`${portainerUrl}/api/endpoints`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
body: formData,
|
||||
dispatcher: insecureAgent,
|
||||
})
|
||||
|
||||
if (!createResponse.ok) {
|
||||
const error = await createResponse.text()
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to create endpoint: ${error}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const endpoint = await createResponse.json() as { Id: number; Name: string }
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Local Docker endpoint created successfully',
|
||||
endpoint,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Portainer:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
153
src/app/api/v1/admin/orders/[id]/portainer/route.ts
Normal file
153
src/app/api/v1/admin/orders/[id]/portainer/route.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { credentialService } from '@/lib/services/credential-service'
|
||||
import { createPortainerClient } from '@/lib/services/portainer-client'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/orders/[id]/portainer
|
||||
* Get Portainer credentials for an order (decrypted)
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId } = await context.params
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: {
|
||||
id: true,
|
||||
serverIp: true,
|
||||
portainerUsername: true,
|
||||
portainerPasswordEnc: true,
|
||||
credentialsSyncedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Construct Portainer URL from server IP (Portainer is accessed directly, not via subdomain)
|
||||
const portainerUrl = order.serverIp ? `https://${order.serverIp}:9443` : null
|
||||
|
||||
// Decrypt password if present
|
||||
let password: string | null = null
|
||||
if (order.portainerPasswordEnc) {
|
||||
try {
|
||||
password = credentialService.decrypt(order.portainerPasswordEnc)
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt Portainer password:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
url: portainerUrl,
|
||||
username: order.portainerUsername,
|
||||
password,
|
||||
syncedAt: order.credentialsSyncedAt,
|
||||
isConfigured: !!(order.portainerUsername && order.portainerPasswordEnc),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/admin/orders/[id]/portainer
|
||||
* Update Portainer credentials (manual entry)
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId } = await context.params
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { username, password } = body
|
||||
|
||||
// Build update data
|
||||
const updateData: {
|
||||
portainerUsername?: string
|
||||
portainerPasswordEnc?: string
|
||||
credentialsSyncedAt?: Date
|
||||
} = {}
|
||||
|
||||
if (username !== undefined) {
|
||||
updateData.portainerUsername = username
|
||||
}
|
||||
|
||||
if (password !== undefined) {
|
||||
updateData.portainerPasswordEnc = credentialService.encrypt(password)
|
||||
}
|
||||
|
||||
// Mark as manually entered (update sync timestamp)
|
||||
updateData.credentialsSyncedAt = new Date()
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/orders/[id]/portainer
|
||||
* Test Portainer connection
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: RouteContext
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId } = await context.params
|
||||
|
||||
try {
|
||||
const client = await createPortainerClient(orderId)
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Portainer credentials not configured',
|
||||
})
|
||||
}
|
||||
|
||||
const connected = await client.testConnection()
|
||||
|
||||
return NextResponse.json({
|
||||
success: connected,
|
||||
error: connected ? null : 'Failed to connect to Portainer',
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Connection test failed',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
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'
|
||||
import { JobStatus, OrderStatus } from '@prisma/client'
|
||||
import { randomBytes } from 'crypto'
|
||||
import {
|
||||
generateJobConfig,
|
||||
decryptPassword,
|
||||
generateRunnerToken,
|
||||
hashRunnerToken,
|
||||
DockerHubCredentials,
|
||||
} from '@/lib/services/config-generator'
|
||||
import { spawnProvisioningContainer, isDockerAvailable } from '@/lib/services/docker-spawner'
|
||||
import { netcupService } from '@/lib/services/netcup-service'
|
||||
import { settingsService } from '@/lib/services/settings-service'
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/orders/[id]/provision
|
||||
* Trigger provisioning for an order
|
||||
*
|
||||
* This spawns a Docker container to handle provisioning asynchronously.
|
||||
* The container streams logs back to the Hub via the job logs API.
|
||||
* Use the SSE endpoint at /logs/stream to monitor progress in real-time.
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
@@ -24,18 +38,25 @@ export async function POST(
|
||||
// Check if order exists and is ready for provisioning
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: {
|
||||
serverConnection: true,
|
||||
},
|
||||
})
|
||||
|
||||
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]
|
||||
// Validate order status - can only provision from SERVER_READY, DNS_READY, or FAILED
|
||||
const validStatuses: OrderStatus[] = [
|
||||
OrderStatus.SERVER_READY,
|
||||
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.`,
|
||||
error: `Cannot provision order in status ${order.status}. Must be SERVER_READY, DNS_READY, or FAILED.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
@@ -49,14 +70,219 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
// Create provisioning job
|
||||
const result = await jobService.createJobForOrder(orderId)
|
||||
const { jobId } = JSON.parse(result)
|
||||
// Validate provisioning config
|
||||
if (!order.customer || !order.companyName || !order.licenseKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Provisioning config not configured. Please set customer, company name, and license key.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check Docker availability
|
||||
if (!await isDockerAvailable()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Docker is not available on the server' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
// Set Netcup hostname if server is linked
|
||||
// Hostname format: {customer}-{first 8 chars of orderId}
|
||||
if (order.netcupServerId && order.customer) {
|
||||
try {
|
||||
await netcupService.setServerHostname(order.netcupServerId, order.customer, orderId)
|
||||
const hostname = `${order.customer.toLowerCase().replace(/[^a-z0-9]/g, '')}-${orderId.slice(0, 8)}`
|
||||
|
||||
await prisma.provisioningLog.create({
|
||||
data: {
|
||||
orderId,
|
||||
level: 'INFO',
|
||||
message: `Set Netcup server hostname to: ${hostname}`,
|
||||
step: 'init',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
// Not critical - log and continue
|
||||
console.error('Failed to set Netcup hostname:', error)
|
||||
await prisma.provisioningLog.create({
|
||||
data: {
|
||||
orderId,
|
||||
level: 'WARN',
|
||||
message: `Could not set Netcup hostname: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
step: 'init',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create or update server connection with registration token
|
||||
let serverConnection = order.serverConnection
|
||||
const registrationToken = `rt_${randomBytes(32).toString('hex')}`
|
||||
|
||||
if (!serverConnection) {
|
||||
// Generate registration token for phone-home
|
||||
serverConnection = await prisma.serverConnection.create({
|
||||
data: {
|
||||
orderId,
|
||||
registrationToken,
|
||||
status: 'PENDING',
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Regenerate token if retrying
|
||||
serverConnection = await prisma.serverConnection.update({
|
||||
where: { id: serverConnection.id },
|
||||
data: {
|
||||
registrationToken,
|
||||
status: 'PENDING',
|
||||
hubApiKey: null, // Clear old API key
|
||||
registeredAt: null,
|
||||
lastHeartbeat: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Generate runner token for container authentication
|
||||
const runnerToken = generateRunnerToken()
|
||||
const runnerTokenHash = hashRunnerToken(runnerToken)
|
||||
|
||||
// Decrypt server password
|
||||
const serverPassword = decryptPassword(order.serverPasswordEncrypted)
|
||||
|
||||
// Fetch Docker Hub credentials from settings
|
||||
const dockerHubSettings = await settingsService.getDockerHubCredentials()
|
||||
let dockerHub: DockerHubCredentials | undefined
|
||||
if (dockerHubSettings.username && dockerHubSettings.token) {
|
||||
dockerHub = {
|
||||
username: dockerHubSettings.username,
|
||||
token: dockerHubSettings.token,
|
||||
registry: dockerHubSettings.registry || undefined,
|
||||
}
|
||||
console.log(`[Provision] Using Docker Hub credentials for user: ${dockerHubSettings.username}`)
|
||||
}
|
||||
|
||||
// Generate job config
|
||||
const jobConfig = generateJobConfig(order, serverPassword, dockerHub)
|
||||
|
||||
// Create provisioning job record
|
||||
const job = await prisma.provisioningJob.create({
|
||||
data: {
|
||||
orderId,
|
||||
jobType: 'PROVISION',
|
||||
status: JobStatus.PENDING,
|
||||
configSnapshot: jobConfig as object,
|
||||
runnerTokenHash,
|
||||
attempt: 1,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
})
|
||||
|
||||
// Update order status to PROVISIONING
|
||||
await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: OrderStatus.PROVISIONING,
|
||||
provisioningStartedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Log the provisioning start
|
||||
await prisma.provisioningLog.create({
|
||||
data: {
|
||||
orderId,
|
||||
level: 'INFO',
|
||||
message: `Provisioning initiated. Spawning Docker container for job ${job.id}.`,
|
||||
step: 'init',
|
||||
},
|
||||
})
|
||||
|
||||
// Determine Hub API URL for container callback
|
||||
// In development, Docker containers need to use host.docker.internal instead of localhost
|
||||
// IMPORTANT: Next.js auto-increments port if 3000 is in use, so we detect the actual port
|
||||
let hubApiUrl: string
|
||||
if (process.env.HUB_URL) {
|
||||
// Production: use explicit HUB_URL
|
||||
hubApiUrl = process.env.HUB_URL
|
||||
} else {
|
||||
// Development: detect actual port from request headers
|
||||
const host = request.headers.get('host') || 'localhost:3000'
|
||||
const port = host.split(':')[1] || '3000'
|
||||
hubApiUrl = `http://host.docker.internal:${port}`
|
||||
}
|
||||
|
||||
// Spawn provisioning container
|
||||
const spawnResult = await spawnProvisioningContainer(
|
||||
job.id,
|
||||
jobConfig,
|
||||
runnerToken,
|
||||
hubApiUrl
|
||||
)
|
||||
|
||||
if (!spawnResult.success) {
|
||||
// Update job status to failed
|
||||
await prisma.provisioningJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: JobStatus.FAILED,
|
||||
error: spawnResult.error,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Update order status to failed
|
||||
await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: OrderStatus.FAILED,
|
||||
failureReason: `Failed to spawn provisioning container: ${spawnResult.error}`,
|
||||
},
|
||||
})
|
||||
|
||||
// Log the failure
|
||||
await prisma.provisioningLog.create({
|
||||
data: {
|
||||
orderId,
|
||||
level: 'ERROR',
|
||||
message: `Failed to spawn container: ${spawnResult.error}`,
|
||||
step: 'init',
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: spawnResult.error || 'Failed to spawn provisioning container',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update job with container info
|
||||
await prisma.provisioningJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: JobStatus.RUNNING,
|
||||
containerName: spawnResult.containerName,
|
||||
claimedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Log successful spawn
|
||||
await prisma.provisioningLog.create({
|
||||
data: {
|
||||
orderId,
|
||||
level: 'INFO',
|
||||
message: `Container ${spawnResult.containerName} spawned successfully.`,
|
||||
step: 'init',
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Provisioning job created',
|
||||
jobId,
|
||||
message: 'Provisioning container spawned',
|
||||
jobId: job.id,
|
||||
containerName: spawnResult.containerName,
|
||||
serverConnectionId: serverConnection.id,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error triggering provisioning:', error)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import crypto from 'crypto'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { OrderStatus } from '@prisma/client'
|
||||
import crypto from 'crypto'
|
||||
import { processAutomation } from '@/lib/services/automation-worker'
|
||||
import { netcupService } from '@/lib/services/netcup-service'
|
||||
import { runDnsVerification } from '@/lib/services/dns-service'
|
||||
import { credentialService } from '@/lib/services/credential-service'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/orders/[id]
|
||||
@@ -101,6 +105,12 @@ export async function PATCH(
|
||||
sshPort?: number
|
||||
serverReadyAt?: Date
|
||||
failureReason?: string
|
||||
tools?: string[]
|
||||
domain?: string
|
||||
customer?: string
|
||||
companyName?: string
|
||||
licenseKey?: string
|
||||
netcupServerId?: string
|
||||
} = {}
|
||||
|
||||
// Handle status update
|
||||
@@ -111,25 +121,136 @@ export async function PATCH(
|
||||
// Handle server credentials
|
||||
if (body.serverIp) {
|
||||
updateData.serverIp = body.serverIp
|
||||
|
||||
// Try to auto-link to Netcup server by IP
|
||||
if (!existingOrder.netcupServerId) {
|
||||
try {
|
||||
const netcupServer = await netcupService.findServerByIp(body.serverIp)
|
||||
if (netcupServer) {
|
||||
updateData.netcupServerId = netcupServer.id
|
||||
console.log(`Auto-linked order ${orderId} to Netcup server ${netcupServer.id} (${netcupServer.name})`)
|
||||
}
|
||||
} catch (error) {
|
||||
// Not critical - just log and continue
|
||||
console.log('Could not auto-link Netcup server:', error instanceof Error ? error.message : 'Not authenticated')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle explicit netcupServerId (manual linking)
|
||||
if (body.netcupServerId) {
|
||||
updateData.netcupServerId = body.netcupServerId
|
||||
}
|
||||
|
||||
if (body.serverPassword) {
|
||||
// Encrypt the password before storing
|
||||
// TODO: Use proper encryption with environment-based key
|
||||
const encrypted = encryptPassword(body.serverPassword)
|
||||
updateData.serverPasswordEncrypted = encrypted
|
||||
updateData.serverPasswordEncrypted = credentialService.encrypt(body.serverPassword)
|
||||
}
|
||||
|
||||
if (body.sshPort) {
|
||||
updateData.sshPort = body.sshPort
|
||||
}
|
||||
|
||||
// If server credentials are being set and status is AWAITING_SERVER, move to SERVER_READY
|
||||
// Handle tools update (only before provisioning starts)
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
// Only allow tools update before provisioning
|
||||
const provisioningStatuses: OrderStatus[] = [
|
||||
OrderStatus.PROVISIONING,
|
||||
OrderStatus.FULFILLED,
|
||||
OrderStatus.EMAIL_CONFIGURED,
|
||||
]
|
||||
if (provisioningStatuses.includes(existingOrder.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot modify tools after provisioning has started' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
updateData.tools = body.tools
|
||||
}
|
||||
|
||||
// Handle domain update (only before provisioning starts)
|
||||
if (body.domain) {
|
||||
const provisioningStatuses: OrderStatus[] = [
|
||||
OrderStatus.PROVISIONING,
|
||||
OrderStatus.FULFILLED,
|
||||
OrderStatus.EMAIL_CONFIGURED,
|
||||
]
|
||||
if (provisioningStatuses.includes(existingOrder.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot modify domain after provisioning has started' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
updateData.domain = body.domain
|
||||
}
|
||||
|
||||
// Handle provisioning config fields (only before provisioning starts)
|
||||
if (body.customer !== undefined || body.companyName !== undefined || body.licenseKey !== undefined) {
|
||||
const provisioningStatuses: OrderStatus[] = [
|
||||
OrderStatus.PROVISIONING,
|
||||
OrderStatus.FULFILLED,
|
||||
OrderStatus.EMAIL_CONFIGURED,
|
||||
]
|
||||
if (provisioningStatuses.includes(existingOrder.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot modify provisioning config after provisioning has started' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (body.customer) {
|
||||
updateData.customer = body.customer
|
||||
}
|
||||
if (body.companyName) {
|
||||
updateData.companyName = body.companyName
|
||||
}
|
||||
if (body.licenseKey) {
|
||||
updateData.licenseKey = body.licenseKey
|
||||
}
|
||||
}
|
||||
|
||||
// Track if we should trigger DNS verification after update
|
||||
let shouldTriggerDnsVerification = false
|
||||
|
||||
// Auto-transition when credentials are saved:
|
||||
// AWAITING_SERVER → DNS_PENDING (skip SERVER_READY, go straight to DNS verification)
|
||||
// This happens regardless of automation mode
|
||||
if (
|
||||
(body.serverIp || body.serverPassword) &&
|
||||
existingOrder.status === OrderStatus.AWAITING_SERVER
|
||||
existingOrder.status === OrderStatus.AWAITING_SERVER &&
|
||||
!body.status // Only auto-transition if no explicit status is provided
|
||||
) {
|
||||
updateData.status = OrderStatus.DNS_PENDING
|
||||
updateData.serverReadyAt = new Date()
|
||||
shouldTriggerDnsVerification = true
|
||||
|
||||
// Auto-populate provisioning config if not already set
|
||||
// This ensures provisioning can proceed without manual config step
|
||||
if (!existingOrder.customer && !updateData.customer) {
|
||||
// Generate customer identifier from domain (e.g., "example.com" → "example")
|
||||
const domain = existingOrder.domain || ''
|
||||
updateData.customer = domain.split('.')[0].toLowerCase().replace(/[^a-z0-9]/g, '') || 'customer'
|
||||
}
|
||||
if (!existingOrder.companyName && !updateData.companyName) {
|
||||
// Use user's company or name, or derive from domain
|
||||
const user = await prisma.user.findUnique({ where: { id: existingOrder.userId } })
|
||||
updateData.companyName = user?.company || user?.name || existingOrder.domain || 'Company'
|
||||
}
|
||||
if (!existingOrder.licenseKey && !updateData.licenseKey) {
|
||||
// Generate a unique license key
|
||||
updateData.licenseKey = `LB-${crypto.randomBytes(8).toString('hex').toUpperCase()}`
|
||||
}
|
||||
}
|
||||
|
||||
// Also trigger DNS verification when explicitly setting status to DNS_PENDING
|
||||
if (body.status === OrderStatus.DNS_PENDING) {
|
||||
shouldTriggerDnsVerification = true
|
||||
}
|
||||
|
||||
// Set serverReadyAt when explicitly setting status to SERVER_READY or DNS_PENDING
|
||||
if (
|
||||
(body.status === OrderStatus.SERVER_READY || body.status === OrderStatus.DNS_PENDING) &&
|
||||
!existingOrder.serverReadyAt
|
||||
) {
|
||||
updateData.status = OrderStatus.SERVER_READY
|
||||
updateData.serverReadyAt = new Date()
|
||||
}
|
||||
|
||||
@@ -143,11 +264,43 @@ export async function PATCH(
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
company: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Trigger DNS verification if credentials were saved or status changed to DNS_PENDING
|
||||
// This runs regardless of automation mode - always verify DNS when credentials are ready
|
||||
if (shouldTriggerDnsVerification && order.serverIp) {
|
||||
try {
|
||||
console.log(`Triggering DNS verification for order ${orderId}`)
|
||||
const dnsResult = await runDnsVerification(orderId)
|
||||
console.log(
|
||||
`DNS verification for order ${orderId}: ${dnsResult.allPassed ? 'PASSED' : 'PENDING'} ` +
|
||||
`(${dnsResult.passedCount}/${dnsResult.totalSubdomains} subdomains)`
|
||||
)
|
||||
// Note: runDnsVerification automatically transitions to DNS_READY if all checks pass
|
||||
} catch (error) {
|
||||
// Log but don't fail the request - DNS verification can be retried
|
||||
console.error('DNS verification failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger automation processing if credentials were saved or status changed
|
||||
// This will check if the order is in AUTO mode and process the next step
|
||||
if (body.serverIp || body.serverPassword || body.status) {
|
||||
try {
|
||||
const result = await processAutomation(orderId)
|
||||
if (result.triggered) {
|
||||
console.log(`Automation triggered for order ${orderId}: ${result.action}`)
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but don't fail the request - automation is async
|
||||
console.error('Automation processing failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(order)
|
||||
} catch (error) {
|
||||
console.error('Error updating order:', error)
|
||||
@@ -158,18 +311,72 @@ export async function PATCH(
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
/**
|
||||
* DELETE /api/v1/admin/orders/[id]
|
||||
* Delete an order and all related records (logs, jobs, DNS verification)
|
||||
* Does NOT touch the actual server - just removes from Hub database
|
||||
*/
|
||||
export async function DELETE(
|
||||
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
|
||||
|
||||
// Find existing order
|
||||
const existingOrder = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: {
|
||||
dnsVerification: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!existingOrder) {
|
||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Delete in correct order to respect foreign key constraints
|
||||
// 1. Delete DNS records (if DNS verification exists)
|
||||
if (existingOrder.dnsVerification) {
|
||||
await prisma.dnsRecord.deleteMany({
|
||||
where: { dnsVerificationId: existingOrder.dnsVerification.id },
|
||||
})
|
||||
await prisma.dnsVerification.delete({
|
||||
where: { id: existingOrder.dnsVerification.id },
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Delete provisioning logs
|
||||
await prisma.provisioningLog.deleteMany({
|
||||
where: { orderId },
|
||||
})
|
||||
|
||||
// 3. Delete jobs
|
||||
await prisma.provisioningJob.deleteMany({
|
||||
where: { orderId },
|
||||
})
|
||||
|
||||
// 4. Delete the order itself
|
||||
await prisma.order.delete({
|
||||
where: { id: orderId },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Order ${orderId} and all related records deleted`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting order:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete order' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
131
src/app/api/v1/admin/orders/[id]/test-ssh/route.ts
Normal file
131
src/app/api/v1/admin/orders/[id]/test-ssh/route.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { testServerConnection } from '@/lib/ansible'
|
||||
import { SSH_PORT_BEFORE_PROVISION } from '@/lib/ssh/constants'
|
||||
import { decryptPassword } from '@/lib/services/config-generator'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/orders/[id]/test-ssh
|
||||
* Test SSH connection to the server
|
||||
*
|
||||
* Accepts optional body with credentials to test before saving:
|
||||
* { serverIp?: string, password?: string, sshPort?: number }
|
||||
*
|
||||
* If body credentials provided, uses those (for testing before save)
|
||||
* Otherwise uses saved order credentials
|
||||
*/
|
||||
export async function POST(request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId } = await context.params
|
||||
|
||||
// Parse optional body for testing unsaved credentials
|
||||
let body: { serverIp?: string; password?: string; sshPort?: number } = {}
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
// No body provided, will use saved credentials
|
||||
}
|
||||
|
||||
// Get order
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Determine which credentials to use
|
||||
// Priority: body params > saved order data
|
||||
const serverIp = body.serverIp || order.serverIp
|
||||
const sshPort = body.sshPort || order.sshPort || SSH_PORT_BEFORE_PROVISION
|
||||
|
||||
// For password: use body.password directly, or decrypt saved password
|
||||
let password: string | null = null
|
||||
if (body.password) {
|
||||
password = body.password
|
||||
} else if (order.serverPasswordEncrypted) {
|
||||
password = decryptPassword(order.serverPasswordEncrypted)
|
||||
}
|
||||
|
||||
// Validate we have required credentials
|
||||
if (!serverIp) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server IP not configured' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server password not configured' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Test SSH connection
|
||||
const result = await testServerConnection(
|
||||
serverIp,
|
||||
password,
|
||||
sshPort
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
// Log successful test
|
||||
await prisma.provisioningLog.create({
|
||||
data: {
|
||||
orderId,
|
||||
level: 'INFO',
|
||||
message: `SSH connection test successful to ${serverIp}:${sshPort} (latency: ${result.latency}ms)`,
|
||||
step: 'ssh-test',
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
latency: result.latency,
|
||||
message: 'SSH connection successful',
|
||||
serverIp,
|
||||
sshPort,
|
||||
})
|
||||
} else {
|
||||
// Log failed test
|
||||
await prisma.provisioningLog.create({
|
||||
data: {
|
||||
orderId,
|
||||
level: 'ERROR',
|
||||
message: `SSH connection test failed to ${serverIp}:${sshPort}: ${result.error}`,
|
||||
step: 'ssh-test',
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: result.error,
|
||||
message: 'SSH connection failed',
|
||||
serverIp,
|
||||
sshPort,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('SSH test error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to test SSH connection' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { OrderStatus, SubscriptionTier, Prisma } from '@prisma/client'
|
||||
import { OrderStatus, SubscriptionTier, AutomationMode, Prisma } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* Generate a customer ID slug from company/user name
|
||||
* Only lowercase letters allowed (env_setup.sh requires ^[a-z]+$)
|
||||
*/
|
||||
function slugifyCustomer(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z]/g, '')
|
||||
.substring(0, 32)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique license key for the order
|
||||
*/
|
||||
function generateLicenseKey(): string {
|
||||
const hex = randomBytes(16).toString('hex')
|
||||
return `lb_inst_${hex}`
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/orders
|
||||
@@ -112,7 +132,13 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Create order
|
||||
// Auto-generate provisioning config from user's company/name
|
||||
const displayName = user.company || user.name || 'customer'
|
||||
const customer = slugifyCustomer(displayName) || 'customer'
|
||||
const companyName = displayName
|
||||
const licenseKey = generateLicenseKey()
|
||||
|
||||
// Create order with MANUAL automation mode (staff-created)
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
userId,
|
||||
@@ -121,6 +147,12 @@ export async function POST(request: NextRequest) {
|
||||
tools,
|
||||
status: OrderStatus.PAYMENT_CONFIRMED,
|
||||
configJson: { tools, tier, domain },
|
||||
automationMode: AutomationMode.MANUAL,
|
||||
source: 'staff',
|
||||
// Auto-generated provisioning config
|
||||
customer,
|
||||
companyName,
|
||||
licenseKey,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
|
||||
114
src/app/api/v1/admin/portainer/ping/route.ts
Normal file
114
src/app/api/v1/admin/portainer/ping/route.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import https from 'https'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/portainer/ping
|
||||
* Check if Portainer is reachable at a given IP
|
||||
*
|
||||
* Query params:
|
||||
* - ip: Server IP address
|
||||
* - port: Portainer port (default: 9443)
|
||||
*/
|
||||
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 ip = searchParams.get('ip')
|
||||
const port = searchParams.get('port') || '9443'
|
||||
|
||||
if (!ip) {
|
||||
return NextResponse.json(
|
||||
{ error: 'IP address required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate IP format (basic check)
|
||||
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/
|
||||
if (!ipRegex.test(ip)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid IP address format' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Use native https module to handle self-signed certificates
|
||||
const result = await new Promise<{ available: boolean; version?: string; instanceId?: string | null; needsSetup?: boolean; error?: string }>((resolve) => {
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: ip,
|
||||
port: parseInt(port),
|
||||
path: '/api/status',
|
||||
method: 'GET',
|
||||
timeout: 5000,
|
||||
rejectUnauthorized: false, // Accept self-signed certificates
|
||||
},
|
||||
(res) => {
|
||||
let data = ''
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk
|
||||
})
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
try {
|
||||
const json = JSON.parse(data)
|
||||
// InstanceID is only set after Portainer has been initialized
|
||||
// (admin account created). If it's empty, Portainer is running
|
||||
// but not configured yet.
|
||||
const isInitialized = !!json.InstanceID
|
||||
resolve({
|
||||
available: isInitialized,
|
||||
version: json.Version || 'unknown',
|
||||
instanceId: json.InstanceID || null,
|
||||
needsSetup: !isInitialized,
|
||||
})
|
||||
} catch {
|
||||
resolve({
|
||||
available: false,
|
||||
version: 'unknown',
|
||||
error: 'Failed to parse response',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
resolve({
|
||||
available: false,
|
||||
error: `Portainer returned status ${res.statusCode}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
req.on('error', (err) => {
|
||||
resolve({
|
||||
available: false,
|
||||
error: err.message,
|
||||
})
|
||||
})
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy()
|
||||
resolve({
|
||||
available: false,
|
||||
error: 'Connection timed out',
|
||||
})
|
||||
})
|
||||
|
||||
req.end()
|
||||
})
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
console.error('Error checking Portainer status:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to check Portainer status' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
176
src/app/api/v1/admin/servers/[id]/command/route.ts
Normal file
176
src/app/api/v1/admin/servers/[id]/command/route.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
interface CommandRequest {
|
||||
type: string // SHELL, RESTART_SERVICE, UPDATE, ECHO, etc.
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Common command types
|
||||
const ALLOWED_COMMAND_TYPES = [
|
||||
'ECHO', // Test connectivity
|
||||
'SHELL', // Execute shell command
|
||||
'RESTART_SERVICE', // Restart a Docker service
|
||||
'UPDATE', // Update orchestrator/agent
|
||||
'DOCKER_COMPOSE_UP', // Start stack
|
||||
'DOCKER_COMPOSE_DOWN', // Stop stack
|
||||
'DOCKER_COMPOSE_RESTART', // Restart stack
|
||||
'GET_LOGS', // Get container logs
|
||||
'GET_STATUS', // Get system status
|
||||
]
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/servers/[id]/command
|
||||
* Get command history for a server
|
||||
*/
|
||||
export async function GET(request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
// Authentication check
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId } = await context.params
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const limit = parseInt(searchParams.get('limit') || '50', 10)
|
||||
const offset = parseInt(searchParams.get('offset') || '0', 10)
|
||||
|
||||
// Find server connection by order ID
|
||||
const serverConnection = await prisma.serverConnection.findUnique({
|
||||
where: { orderId },
|
||||
})
|
||||
|
||||
if (!serverConnection) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server connection not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get command history
|
||||
const [commands, total] = await Promise.all([
|
||||
prisma.remoteCommand.findMany({
|
||||
where: { serverConnectionId: serverConnection.id },
|
||||
orderBy: { queuedAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
prisma.remoteCommand.count({
|
||||
where: { serverConnectionId: serverConnection.id },
|
||||
}),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
commands,
|
||||
pagination: {
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + commands.length < total,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get commands error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/servers/[id]/command
|
||||
* Queue a command for a server
|
||||
*/
|
||||
export async function POST(request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
// Authentication check
|
||||
const session = await auth()
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: orderId } = await context.params
|
||||
const body: CommandRequest = await request.json()
|
||||
|
||||
// Validate command type
|
||||
if (!body.type) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required field: type' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!ALLOWED_COMMAND_TYPES.includes(body.type)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Invalid command type. Allowed types: ${ALLOWED_COMMAND_TYPES.join(', ')}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Find server connection by order ID
|
||||
const serverConnection = await prisma.serverConnection.findUnique({
|
||||
where: { orderId },
|
||||
include: {
|
||||
order: {
|
||||
select: { domain: true, serverIp: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!serverConnection) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server connection not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if server is registered
|
||||
if (serverConnection.status === 'PENDING') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server has not registered with Hub yet' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Queue the command
|
||||
const command = await prisma.remoteCommand.create({
|
||||
data: {
|
||||
serverConnectionId: serverConnection.id,
|
||||
type: body.type,
|
||||
payload: (body.payload || {}) as object,
|
||||
initiatedBy: session?.user?.email || 'unknown',
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
command: {
|
||||
id: command.id,
|
||||
type: command.type,
|
||||
status: command.status,
|
||||
queuedAt: command.queuedAt,
|
||||
},
|
||||
server: {
|
||||
domain: serverConnection.order.domain,
|
||||
ip: serverConnection.order.serverIp,
|
||||
status: serverConnection.status,
|
||||
lastHeartbeat: serverConnection.lastHeartbeat,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Queue command error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
158
src/app/api/v1/admin/servers/[id]/health/route.ts
Normal file
158
src/app/api/v1/admin/servers/[id]/health/route.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
// Threshold for considering a server offline (5 minutes without heartbeat)
|
||||
const OFFLINE_THRESHOLD_MS = 5 * 60 * 1000
|
||||
// Threshold for degraded status (2 minutes without heartbeat)
|
||||
const DEGRADED_THRESHOLD_MS = 2 * 60 * 1000
|
||||
|
||||
type HealthStatus = 'unknown' | 'pending' | 'online' | 'degraded' | 'offline'
|
||||
|
||||
function calculateHealthStatus(
|
||||
connectionStatus: string,
|
||||
lastHeartbeat: Date | null
|
||||
): HealthStatus {
|
||||
if (connectionStatus === 'PENDING') {
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
if (!lastHeartbeat) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
const timeSinceHeartbeat = Date.now() - lastHeartbeat.getTime()
|
||||
|
||||
if (timeSinceHeartbeat > OFFLINE_THRESHOLD_MS) {
|
||||
return 'offline'
|
||||
}
|
||||
|
||||
if (timeSinceHeartbeat > DEGRADED_THRESHOLD_MS) {
|
||||
return 'degraded'
|
||||
}
|
||||
|
||||
return 'online'
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/servers/[id]/health
|
||||
* Get health status for a server
|
||||
*/
|
||||
export async function GET(_request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { id: orderId } = await context.params
|
||||
|
||||
// Find order with server connection
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: {
|
||||
serverConnection: true,
|
||||
user: {
|
||||
select: { email: true, name: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Order not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const serverConnection = order.serverConnection
|
||||
|
||||
// If no server connection exists yet
|
||||
if (!serverConnection) {
|
||||
return NextResponse.json({
|
||||
orderId: order.id,
|
||||
domain: order.domain,
|
||||
serverIp: order.serverIp,
|
||||
health: {
|
||||
status: 'not_provisioned' as const,
|
||||
message: 'Server has not been provisioned yet',
|
||||
},
|
||||
connection: null,
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate health status
|
||||
const healthStatus = calculateHealthStatus(
|
||||
serverConnection.status,
|
||||
serverConnection.lastHeartbeat
|
||||
)
|
||||
|
||||
// Get recent command stats
|
||||
const [pendingCommands, recentFailedCommands] = await Promise.all([
|
||||
prisma.remoteCommand.count({
|
||||
where: {
|
||||
serverConnectionId: serverConnection.id,
|
||||
status: { in: ['PENDING', 'SENT', 'EXECUTING'] },
|
||||
},
|
||||
}),
|
||||
prisma.remoteCommand.count({
|
||||
where: {
|
||||
serverConnectionId: serverConnection.id,
|
||||
status: 'FAILED',
|
||||
completedAt: {
|
||||
gte: new Date(Date.now() - 24 * 60 * 60 * 1000), // Last 24 hours
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const timeSinceHeartbeat = serverConnection.lastHeartbeat
|
||||
? Date.now() - serverConnection.lastHeartbeat.getTime()
|
||||
: null
|
||||
|
||||
return NextResponse.json({
|
||||
orderId: order.id,
|
||||
domain: order.domain,
|
||||
serverIp: order.serverIp,
|
||||
orderStatus: order.status,
|
||||
health: {
|
||||
status: healthStatus,
|
||||
message: getHealthMessage(healthStatus, timeSinceHeartbeat),
|
||||
},
|
||||
connection: {
|
||||
id: serverConnection.id,
|
||||
status: serverConnection.status,
|
||||
registeredAt: serverConnection.registeredAt,
|
||||
lastHeartbeat: serverConnection.lastHeartbeat,
|
||||
timeSinceHeartbeat,
|
||||
orchestratorUrl: serverConnection.orchestratorUrl,
|
||||
agentVersion: serverConnection.agentVersion,
|
||||
},
|
||||
commands: {
|
||||
pending: pendingCommands,
|
||||
recentFailures: recentFailedCommands,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Health check error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getHealthMessage(status: HealthStatus, timeSinceHeartbeat: number | null): string {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'Server is healthy and responding'
|
||||
case 'degraded':
|
||||
return `Last heartbeat ${Math.round((timeSinceHeartbeat || 0) / 1000)}s ago - may be experiencing issues`
|
||||
case 'offline':
|
||||
return `No heartbeat for ${Math.round((timeSinceHeartbeat || 0) / 60000)} minutes - server appears offline`
|
||||
case 'pending':
|
||||
return 'Waiting for orchestrator to register with Hub'
|
||||
case 'unknown':
|
||||
return 'Server status unknown - no heartbeat data available'
|
||||
default:
|
||||
return 'Unable to determine server status'
|
||||
}
|
||||
}
|
||||
144
src/app/api/v1/admin/settings/[key]/route.ts
Normal file
144
src/app/api/v1/admin/settings/[key]/route.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { settingsService, SETTING_KEYS, SettingKey } from '@/lib/services/settings-service'
|
||||
|
||||
type RouteParams = { params: Promise<{ key: string }> }
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/settings/{key}
|
||||
* Get a single setting value
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { key } = await params
|
||||
|
||||
if (!(key in SETTING_KEYS)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown setting key: ${key}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const config = SETTING_KEYS[key as SettingKey]
|
||||
const value = await settingsService.get(key as SettingKey)
|
||||
|
||||
// Don't return actual encrypted values via individual get
|
||||
if (config.encrypted && value) {
|
||||
return NextResponse.json({
|
||||
key,
|
||||
hasValue: true,
|
||||
encrypted: true,
|
||||
category: config.category,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
key,
|
||||
value: value || '',
|
||||
hasValue: !!value,
|
||||
encrypted: config.encrypted,
|
||||
category: config.category,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error getting setting:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get setting' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/admin/settings/{key}
|
||||
* Update a single setting
|
||||
*/
|
||||
export async function PUT(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { key } = await params
|
||||
|
||||
if (!(key in SETTING_KEYS)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown setting key: ${key}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { value } = body as { value: string }
|
||||
|
||||
if (value === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Value is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await settingsService.set(key as SettingKey, value)
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Setting updated successfully',
|
||||
key,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating setting:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update setting' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/settings/{key}
|
||||
* Remove a setting (revert to default)
|
||||
*/
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session || session.user.userType !== 'staff') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { key } = await params
|
||||
|
||||
if (!(key in SETTING_KEYS)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown setting key: ${key}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const deleted = await settingsService.delete(key as SettingKey)
|
||||
|
||||
if (deleted) {
|
||||
return NextResponse.json({
|
||||
message: 'Setting removed (reverted to default)',
|
||||
key,
|
||||
})
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
message: 'Setting was not set',
|
||||
key,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting setting:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete setting' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
65
src/app/api/v1/admin/settings/email/test/route.ts
Normal file
65
src/app/api/v1/admin/settings/email/test/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireStaffPermission } from '@/lib/auth-helpers'
|
||||
import { emailService } from '@/lib/services/email-service'
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/settings/email/test
|
||||
* Test SMTP connection and optionally send a test email
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await requireStaffPermission('settings:edit')
|
||||
} catch (err) {
|
||||
const error = err as { status?: number; message?: string }
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Unauthorized' },
|
||||
{ status: error.status || 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { testEmail } = body as { testEmail?: string }
|
||||
|
||||
// First, test the connection
|
||||
const connectionResult = await emailService.testConnection()
|
||||
if (!connectionResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: connectionResult.error,
|
||||
connectionTest: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// If a test email address is provided, send a test email
|
||||
if (testEmail) {
|
||||
const sendResult = await emailService.sendTestEmail(testEmail)
|
||||
return NextResponse.json({
|
||||
success: sendResult.success,
|
||||
connectionTest: true,
|
||||
emailSent: sendResult.success,
|
||||
error: sendResult.error,
|
||||
messageId: sendResult.messageId,
|
||||
})
|
||||
}
|
||||
|
||||
// Just connection test
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
connectionTest: true,
|
||||
emailSent: false,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Email test error:', err)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to test email',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
108
src/app/api/v1/admin/settings/route.ts
Normal file
108
src/app/api/v1/admin/settings/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { settingsService, SETTING_KEYS, SettingKey } from '@/lib/services/settings-service'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/settings
|
||||
* List all settings (with masked values for encrypted ones)
|
||||
*/
|
||||
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 category = searchParams.get('category')
|
||||
|
||||
if (category) {
|
||||
const settings = await settingsService.getCategory(category)
|
||||
return NextResponse.json({ settings })
|
||||
}
|
||||
|
||||
const settings = await settingsService.list()
|
||||
|
||||
// Group by category for easier UI consumption
|
||||
const grouped: Record<string, typeof settings> = {}
|
||||
for (const setting of settings) {
|
||||
if (!grouped[setting.category]) {
|
||||
grouped[setting.category] = []
|
||||
}
|
||||
grouped[setting.category].push(setting)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
settings,
|
||||
grouped,
|
||||
categories: Object.keys(grouped).sort(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error listing settings:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list settings' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/admin/settings
|
||||
* Batch update settings
|
||||
*/
|
||||
export async function PUT(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 { settings } = body as { settings: Array<{ key: string; value: string }> }
|
||||
|
||||
if (!settings || !Array.isArray(settings)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Settings array is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate all keys
|
||||
for (const { key } of settings) {
|
||||
if (!(key in SETTING_KEYS)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown setting key: ${key}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out empty values for encrypted fields (don't overwrite with empty)
|
||||
const validSettings = settings.filter(({ key, value }) => {
|
||||
const config = SETTING_KEYS[key as SettingKey]
|
||||
// For encrypted fields, skip if value is empty (user didn't want to change it)
|
||||
if (config.encrypted && !value) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}) as Array<{ key: SettingKey; value: string }>
|
||||
|
||||
await settingsService.batchUpdate(validSettings)
|
||||
|
||||
// Return updated settings
|
||||
const updatedSettings = await settingsService.list()
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Settings updated successfully',
|
||||
settings: updatedSettings,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating settings:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update settings' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
45
src/app/api/v1/admin/settings/storage/test/route.ts
Normal file
45
src/app/api/v1/admin/settings/storage/test/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireStaffPermission } from '@/lib/auth-helpers'
|
||||
import { storageService } from '@/lib/services/storage-service'
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/settings/storage/test
|
||||
* Test S3/MinIO connection
|
||||
* Requires: settings:edit permission
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await requireStaffPermission('settings:edit')
|
||||
|
||||
// Get optional config override from body
|
||||
const body = await request.json().catch(() => ({}))
|
||||
|
||||
// If config is provided in body, test with that config (for testing before saving)
|
||||
if (body.endpoint && body.bucket && body.accessKey && body.secretKey) {
|
||||
const result = await storageService.testConnection({
|
||||
endpoint: body.endpoint,
|
||||
bucket: body.bucket,
|
||||
accessKey: body.accessKey,
|
||||
secretKey: body.secretKey,
|
||||
region: body.region || 'us-east-1',
|
||||
useSsl: body.useSsl !== false,
|
||||
})
|
||||
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
|
||||
// Otherwise test with stored settings
|
||||
const result = await storageService.testConnection()
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'status' in error) {
|
||||
const err = error as { status: number; message: string }
|
||||
return NextResponse.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
console.error('Error testing storage connection:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Failed to test storage connection' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
221
src/app/api/v1/admin/staff/[id]/route.ts
Normal file
221
src/app/api/v1/admin/staff/[id]/route.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireStaffPermission } from '@/lib/auth-helpers'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { StaffRole, StaffStatus } from '@prisma/client'
|
||||
import { canDeleteRole, getAssignableRoles } from '@/lib/services/permission-service'
|
||||
|
||||
interface UpdateStaffRequest {
|
||||
role?: StaffRole
|
||||
status?: StaffStatus
|
||||
name?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/staff/[id]
|
||||
* Get staff member details
|
||||
* Requires: staff:view permission
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requireStaffPermission('staff:view')
|
||||
const { id } = await params
|
||||
|
||||
const staff = await prisma.staff.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
status: true,
|
||||
invitedBy: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorVerifiedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!staff) {
|
||||
return NextResponse.json({ error: 'Staff not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get inviter details if applicable
|
||||
let invitedByStaff = null
|
||||
if (staff.invitedBy) {
|
||||
invitedByStaff = await prisma.staff.findUnique({
|
||||
where: { id: staff.invitedBy },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...staff,
|
||||
invitedByStaff,
|
||||
})
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'status' in error) {
|
||||
const err = error as { status: number; message: string }
|
||||
return NextResponse.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
console.error('Error getting staff:', error)
|
||||
return NextResponse.json({ error: 'Failed to get staff' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/admin/staff/[id]
|
||||
* Update staff member (role, status, name)
|
||||
* Requires: staff:manage permission
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await requireStaffPermission('staff:manage')
|
||||
const { id } = await params
|
||||
const body: UpdateStaffRequest = await request.json()
|
||||
|
||||
// Get the target staff member
|
||||
const targetStaff = await prisma.staff.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, role: true },
|
||||
})
|
||||
|
||||
if (!targetStaff) {
|
||||
return NextResponse.json({ error: 'Staff not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if trying to modify self
|
||||
if (id === session.user.id) {
|
||||
// Can only update own name
|
||||
if (body.role || body.status) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot modify your own role or status' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate role change is allowed
|
||||
if (body.role) {
|
||||
const assignableRoles = getAssignableRoles(session.user.role)
|
||||
if (!assignableRoles.includes(body.role)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot assign this role' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Cannot change OWNER role if you're not OWNER
|
||||
if (targetStaff.role === 'OWNER' && session.user.role !== 'OWNER') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot modify an OWNER' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Build update data
|
||||
const updateData: Partial<UpdateStaffRequest> = {}
|
||||
if (body.name !== undefined) updateData.name = body.name
|
||||
if (body.role !== undefined) updateData.role = body.role
|
||||
if (body.status !== undefined) updateData.status = body.status
|
||||
|
||||
const updatedStaff = await prisma.staff.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
status: true,
|
||||
twoFactorEnabled: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(updatedStaff)
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'status' in error) {
|
||||
const err = error as { status: number; message: string }
|
||||
return NextResponse.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
console.error('Error updating staff:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update staff' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/staff/[id]
|
||||
* Delete staff member
|
||||
* Requires: staff:delete permission (OWNER only)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await requireStaffPermission('staff:delete')
|
||||
const { id } = await params
|
||||
|
||||
// Cannot delete yourself
|
||||
if (id === session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot delete yourself' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get the target staff member
|
||||
const targetStaff = await prisma.staff.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, role: true, email: true },
|
||||
})
|
||||
|
||||
if (!targetStaff) {
|
||||
return NextResponse.json({ error: 'Staff not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if can delete this role
|
||||
if (!canDeleteRole(session.user.role, targetStaff.role)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot delete staff with this role' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Prevent deleting another OWNER
|
||||
if (targetStaff.role === 'OWNER') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot delete an OWNER' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.staff.delete({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ deleted: true, email: targetStaff.email })
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'status' in error) {
|
||||
const err = error as { status: number; message: string }
|
||||
return NextResponse.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
console.error('Error deleting staff:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete staff' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
199
src/app/api/v1/admin/staff/invite/route.ts
Normal file
199
src/app/api/v1/admin/staff/invite/route.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import crypto from 'crypto'
|
||||
import { requireStaffPermission } from '@/lib/auth-helpers'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { StaffRole } from '@prisma/client'
|
||||
import { getAssignableRoles } from '@/lib/services/permission-service'
|
||||
import { emailService } from '@/lib/services/email-service'
|
||||
|
||||
interface InviteStaffRequest {
|
||||
email: string
|
||||
role: StaffRole
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/staff/invite
|
||||
* Send a staff invitation
|
||||
* Requires: staff:invite permission
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await requireStaffPermission('staff:invite')
|
||||
const body: InviteStaffRequest = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!body.role) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Role is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(body.email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email format' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate role can be assigned
|
||||
const assignableRoles = getAssignableRoles(session.user.role)
|
||||
if (!assignableRoles.includes(body.role)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot invite staff with this role' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if email already exists as staff
|
||||
const existingStaff = await prisma.staff.findUnique({
|
||||
where: { email: body.email },
|
||||
})
|
||||
|
||||
if (existingStaff) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A staff member with this email already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if pending invitation exists
|
||||
const existingInvite = await prisma.staffInvitation.findUnique({
|
||||
where: { email: body.email },
|
||||
})
|
||||
|
||||
if (existingInvite) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A pending invitation for this email already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate secure token
|
||||
const token = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
// Create invitation with 7-day expiry
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 7)
|
||||
|
||||
const invitation = await prisma.staffInvitation.create({
|
||||
data: {
|
||||
email: body.email,
|
||||
role: body.role,
|
||||
token,
|
||||
expiresAt,
|
||||
invitedBy: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Get the inviter's name for the response
|
||||
const inviter = await prisma.staff.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { name: true, email: true },
|
||||
})
|
||||
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
||||
const inviteUrl = `${baseUrl}/invite/${token}`
|
||||
|
||||
// Send invitation email
|
||||
let emailSent = false
|
||||
let emailError: string | undefined
|
||||
|
||||
const isEmailConfigured = await emailService.isConfigured()
|
||||
if (isEmailConfigured) {
|
||||
const inviterName = inviter?.name || inviter?.email || 'A team member'
|
||||
const roleDisplay = body.role.charAt(0) + body.role.slice(1).toLowerCase()
|
||||
|
||||
const emailResult = await emailService.sendEmail({
|
||||
to: body.email,
|
||||
subject: `You've been invited to join LetsBe Hub`,
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background-color: #667eea; padding: 30px; text-align: center;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 28px;">LetsBe Hub</h1>
|
||||
</div>
|
||||
<div style="padding: 40px 30px; background-color: #f9f9f9;">
|
||||
<h2 style="color: #333333; margin-top: 0;">You're Invited!</h2>
|
||||
<p style="color: #666666; line-height: 1.6; font-size: 16px;">
|
||||
${inviterName} has invited you to join <strong>LetsBe Hub</strong> as a <strong>${roleDisplay}</strong>.
|
||||
</p>
|
||||
<p style="color: #666666; line-height: 1.6; font-size: 16px;">
|
||||
Click the button below to create your account and get started.
|
||||
</p>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin: 30px auto;">
|
||||
<tr>
|
||||
<td style="border-radius: 6px; background-color: #667eea;">
|
||||
<a href="${inviteUrl}" target="_blank" style="background-color: #667eea; border: 15px solid #667eea; font-family: Arial, sans-serif; font-size: 16px; line-height: 1.1; text-align: center; text-decoration: none; display: block; border-radius: 6px; font-weight: bold;">
|
||||
<span style="color: #ffffff;">Accept Invitation</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="color: #999999; font-size: 14px; margin-top: 30px;">
|
||||
Or copy and paste this link into your browser:
|
||||
</p>
|
||||
<p style="color: #667eea; font-size: 14px; word-break: break-all;">
|
||||
<a href="${inviteUrl}" style="color: #667eea;">${inviteUrl}</a>
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #dddddd; margin: 30px 0;" />
|
||||
<p style="color: #999999; font-size: 12px;">
|
||||
This invitation expires in 7 days. If you didn't expect this invitation, you can safely ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
<div style="padding: 20px; background-color: #333333; text-align: center;">
|
||||
<p style="color: #999999; margin: 0; font-size: 12px;">
|
||||
LetsBe Hub - Infrastructure Management Platform
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
text: `You've been invited to join LetsBe Hub!\n\n${inviterName} has invited you to join LetsBe Hub as a ${roleDisplay}.\n\nClick here to accept: ${inviteUrl}\n\nThis invitation expires in 7 days.`,
|
||||
})
|
||||
|
||||
emailSent = emailResult.success
|
||||
emailError = emailResult.error
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
...invitation,
|
||||
invitedBy: inviter,
|
||||
inviteUrl, // Always include URL so it can be copied manually if email fails
|
||||
emailSent,
|
||||
emailError,
|
||||
message: emailSent
|
||||
? 'Invitation sent successfully'
|
||||
: isEmailConfigured
|
||||
? 'Invitation created but email failed to send'
|
||||
: 'Invitation created (email not configured)',
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'status' in error) {
|
||||
const err = error as { status: number; message: string }
|
||||
return NextResponse.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
console.error('Error creating invitation:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create invitation' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
106
src/app/api/v1/admin/staff/invites/[id]/route.ts
Normal file
106
src/app/api/v1/admin/staff/invites/[id]/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireStaffPermission } from '@/lib/auth-helpers'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/staff/invites/[id]
|
||||
* Get invitation details
|
||||
* Requires: staff:view permission
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requireStaffPermission('staff:view')
|
||||
const { id } = await params
|
||||
|
||||
const invitation = await prisma.staffInvitation.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
expiresAt: true,
|
||||
invitedBy: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invitation not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get inviter details
|
||||
const inviter = await prisma.staff.findUnique({
|
||||
where: { id: invitation.invitedBy },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
...invitation,
|
||||
isExpired: invitation.expiresAt < new Date(),
|
||||
invitedByStaff: inviter,
|
||||
})
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'status' in error) {
|
||||
const err = error as { status: number; message: string }
|
||||
return NextResponse.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
console.error('Error getting invitation:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get invitation' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/staff/invites/[id]
|
||||
* Cancel/delete a staff invitation
|
||||
* Requires: staff:invite permission
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requireStaffPermission('staff:invite')
|
||||
const { id } = await params
|
||||
|
||||
// Check if invitation exists
|
||||
const invitation = await prisma.staffInvitation.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, email: true },
|
||||
})
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invitation not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.staffInvitation.delete({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
deleted: true,
|
||||
email: invitation.email,
|
||||
})
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'status' in error) {
|
||||
const err = error as { status: number; message: string }
|
||||
return NextResponse.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
console.error('Error deleting invitation:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete invitation' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
66
src/app/api/v1/admin/staff/invites/route.ts
Normal file
66
src/app/api/v1/admin/staff/invites/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireStaffPermission } from '@/lib/auth-helpers'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/staff/invites
|
||||
* List pending staff invitations
|
||||
* Requires: staff:view permission
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
await requireStaffPermission('staff:view')
|
||||
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const includeExpired = searchParams.get('includeExpired') === 'true'
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const where = includeExpired
|
||||
? {}
|
||||
: { expiresAt: { gt: now } }
|
||||
|
||||
const invitations = await prisma.staffInvitation.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
expiresAt: true,
|
||||
invitedBy: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// Get inviter details
|
||||
const inviterIds = [...new Set(invitations.map((i) => i.invitedBy))]
|
||||
const inviters = await prisma.staff.findMany({
|
||||
where: { id: { in: inviterIds } },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
|
||||
const inviterMap = new Map(inviters.map((i) => [i.id, i]))
|
||||
|
||||
const invitationsWithDetails = invitations.map((inv) => ({
|
||||
...inv,
|
||||
isExpired: inv.expiresAt < now,
|
||||
invitedByStaff: inviterMap.get(inv.invitedBy) || null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
invitations: invitationsWithDetails,
|
||||
total: invitationsWithDetails.length,
|
||||
})
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'status' in error) {
|
||||
const err = error as { status: number; message: string }
|
||||
return NextResponse.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
console.error('Error listing invitations:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list invitations' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
94
src/app/api/v1/admin/staff/route.ts
Normal file
94
src/app/api/v1/admin/staff/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireStaffPermission } from '@/lib/auth-helpers'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { StaffStatus, Prisma } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/staff
|
||||
* List all staff members
|
||||
* Requires: staff:view permission
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await requireStaffPermission('staff:view')
|
||||
const currentUserId = session.user.id
|
||||
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const status = searchParams.get('status') as StaffStatus | null
|
||||
const search = searchParams.get('search')
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
|
||||
const where: Prisma.StaffWhereInput = {}
|
||||
|
||||
if (status) {
|
||||
where.status = status
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
const [staff, total] = await Promise.all([
|
||||
prisma.staff.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
status: true,
|
||||
invitedBy: true,
|
||||
twoFactorEnabled: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.staff.count({ where }),
|
||||
])
|
||||
|
||||
// Get inviter names for staff who were invited
|
||||
const inviterIds = staff
|
||||
.map((s) => s.invitedBy)
|
||||
.filter((id): id is string => id !== null)
|
||||
|
||||
const inviters = await prisma.staff.findMany({
|
||||
where: { id: { in: inviterIds } },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
|
||||
const inviterMap = new Map(inviters.map((i) => [i.id, i]))
|
||||
|
||||
const staffWithInviter = staff.map((s) => ({
|
||||
...s,
|
||||
isCurrentUser: s.id === currentUserId,
|
||||
invitedByStaff: s.invitedBy ? inviterMap.get(s.invitedBy) || null : null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
staff: staffWithInviter,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'status' in error) {
|
||||
const err = error as { status: number; message: string }
|
||||
return NextResponse.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
console.error('Error listing staff:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list staff' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user