Complete Hub Admin Dashboard with analytics, settings, and enterprise features
Some checks failed
Build and Push Docker Image / lint-and-typecheck (push) Failing after 2m10s
Build and Push Docker Image / build (push) Has been skipped

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:
2026-01-17 12:33:11 +01:00
parent 60493cfbdd
commit 92092760a7
234 changed files with 52896 additions and 2425 deletions

View 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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View 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 }
)
}
}

View File

@@ -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 }
)
}
}

View 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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View 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; 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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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}`,
})
}
}

View File

@@ -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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View File

@@ -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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View File

@@ -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() {

View 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 }
)
}
}

View 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',
})
}
}

View File

@@ -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)

View File

@@ -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 }
)
}
}

View 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 }
)
}
}

View File

@@ -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: {

View 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 }
)
}
}

View 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 }
)
}
}

View 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'
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}