initial commit
This commit is contained in:
183
app/api/admin/billing/route.ts
Normal file
183
app/api/admin/billing/route.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { withAdminAuth } from '@/lib/admin-middleware'
|
||||
import { connectDB } from '@/lib/mongodb'
|
||||
import { Billing } from '@/models/billing'
|
||||
import { Transaction } from '@/models/transaction'
|
||||
import { z } from 'zod'
|
||||
|
||||
const BillingUpdateSchema = z.object({
|
||||
payment_status: z.enum(['pending', 'completed', 'failed', 'refunded']).optional(),
|
||||
service_status: z.enum(['active', 'inactive', 'suspended', 'cancelled']).optional(),
|
||||
amount: z.number().min(0).optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return withAdminAuth(request, async (req, admin) => {
|
||||
try {
|
||||
await connectDB()
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const search = searchParams.get('search') || ''
|
||||
const serviceType = searchParams.get('serviceType') || ''
|
||||
const paymentStatus = searchParams.get('paymentStatus') || ''
|
||||
const dateFrom = searchParams.get('dateFrom')
|
||||
const dateTo = searchParams.get('dateTo')
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
// Build filter query
|
||||
const filter: any = {}
|
||||
|
||||
if (search) {
|
||||
filter.$or = [
|
||||
{ user_email: { $regex: search, $options: 'i' } },
|
||||
{ silicon_id: { $regex: search, $options: 'i' } },
|
||||
{ service_name: { $regex: search, $options: 'i' } },
|
||||
]
|
||||
}
|
||||
|
||||
if (serviceType && serviceType !== 'all') {
|
||||
filter.service_type = serviceType
|
||||
}
|
||||
|
||||
if (paymentStatus && paymentStatus !== 'all') {
|
||||
filter.payment_status = paymentStatus
|
||||
}
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
filter.created_at = {}
|
||||
if (dateFrom) filter.created_at.$gte = new Date(dateFrom)
|
||||
if (dateTo) filter.created_at.$lte = new Date(dateTo)
|
||||
}
|
||||
|
||||
const [billings, totalBillings] = await Promise.all([
|
||||
Billing.find(filter).sort({ created_at: -1 }).skip(skip).limit(limit),
|
||||
Billing.countDocuments(filter),
|
||||
])
|
||||
|
||||
const totalPages = Math.ceil(totalBillings / limit)
|
||||
|
||||
// Calculate summary statistics for current filter
|
||||
const summaryStats = await Billing.aggregate([
|
||||
{ $match: filter },
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
totalAmount: { $sum: '$amount' },
|
||||
totalTax: { $sum: '$tax_amount' },
|
||||
totalDiscount: { $sum: '$discount_applied' },
|
||||
completedCount: {
|
||||
$sum: { $cond: [{ $eq: ['$payment_status', 'completed'] }, 1, 0] },
|
||||
},
|
||||
pendingCount: {
|
||||
$sum: { $cond: [{ $eq: ['$payment_status', 'pending'] }, 1, 0] },
|
||||
},
|
||||
failedCount: {
|
||||
$sum: { $cond: [{ $eq: ['$payment_status', 'failed'] }, 1, 0] },
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
billings,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
totalPages,
|
||||
totalBillings,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1,
|
||||
},
|
||||
summary: summaryStats[0] || {
|
||||
totalAmount: 0,
|
||||
totalTax: 0,
|
||||
totalDiscount: 0,
|
||||
completedCount: 0,
|
||||
pendingCount: 0,
|
||||
failedCount: 0,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Admin billing fetch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch billing data' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return withAdminAuth(request, async (req, admin) => {
|
||||
try {
|
||||
await connectDB()
|
||||
|
||||
const body = await request.json()
|
||||
const { action, billingId, data } = body
|
||||
|
||||
if (action === 'update') {
|
||||
const validatedData = BillingUpdateSchema.parse(data)
|
||||
|
||||
const billing = await Billing.findByIdAndUpdate(
|
||||
billingId,
|
||||
{
|
||||
...validatedData,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
{ new: true, runValidators: true }
|
||||
)
|
||||
|
||||
if (!billing) {
|
||||
return NextResponse.json({ error: 'Billing record not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ billing })
|
||||
}
|
||||
|
||||
if (action === 'refund') {
|
||||
const billing = await Billing.findById(billingId)
|
||||
|
||||
if (!billing) {
|
||||
return NextResponse.json({ error: 'Billing record not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (billing.payment_status !== 'paid') {
|
||||
return NextResponse.json({ error: 'Can only refund paid payments' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Update billing status
|
||||
billing.payment_status = 'refunded'
|
||||
billing.status = 'cancelled'
|
||||
await billing.save()
|
||||
|
||||
// Create refund transaction
|
||||
const refundTransaction = new Transaction({
|
||||
userId: billing.user_id,
|
||||
type: 'credit',
|
||||
amount: billing.amount,
|
||||
description: `Refund for ${billing.service_name}`,
|
||||
status: 'completed',
|
||||
reference: `refund_${billing._id}`,
|
||||
metadata: {
|
||||
originalBillingId: billing._id,
|
||||
refundedBy: admin.id,
|
||||
refundReason: data.refundReason || 'Admin refund',
|
||||
},
|
||||
})
|
||||
|
||||
await refundTransaction.save()
|
||||
|
||||
return NextResponse.json({
|
||||
billing,
|
||||
transaction: refundTransaction,
|
||||
message: 'Refund processed successfully',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error('Admin billing action error:', error)
|
||||
return NextResponse.json({ error: 'Failed to perform billing action' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
}
|
||||
155
app/api/admin/dashboard/route.ts
Normal file
155
app/api/admin/dashboard/route.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { withAdminAuth } from '@/lib/admin-middleware'
|
||||
import { connectDB } from '@/lib/mongodb'
|
||||
import { User } from '@/models/user'
|
||||
import { Transaction } from '@/models/transaction'
|
||||
import { Billing } from '@/models/billing'
|
||||
import { DeveloperRequest } from '@/models/developer-request'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return withAdminAuth(request, async (req, admin) => {
|
||||
try {
|
||||
await connectDB()
|
||||
|
||||
// Get current date for time-based queries
|
||||
const now = new Date()
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const startOfWeek = new Date(now.setDate(now.getDate() - now.getDay()))
|
||||
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
// User statistics
|
||||
const totalUsers = await User.countDocuments()
|
||||
const activeUsers = await User.countDocuments({ lastLogin: { $gte: startOfMonth } })
|
||||
const newUsersThisMonth = await User.countDocuments({ createdAt: { $gte: startOfMonth } })
|
||||
const verifiedUsers = await User.countDocuments({ isVerified: true })
|
||||
|
||||
// Financial statistics
|
||||
const totalRevenue = await Billing.aggregate([
|
||||
{ $group: { _id: null, total: { $sum: '$amount' } } },
|
||||
])
|
||||
|
||||
const monthlyRevenue = await Billing.aggregate([
|
||||
{ $match: { created_at: { $gte: startOfMonth } } },
|
||||
{ $group: { _id: null, total: { $sum: '$amount' } } },
|
||||
])
|
||||
|
||||
const weeklyRevenue = await Billing.aggregate([
|
||||
{ $match: { created_at: { $gte: startOfWeek } } },
|
||||
{ $group: { _id: null, total: { $sum: '$amount' } } },
|
||||
])
|
||||
|
||||
// Transaction statistics
|
||||
const totalTransactions = await Transaction.countDocuments()
|
||||
const monthlyTransactions = await Transaction.countDocuments({
|
||||
createdAt: { $gte: startOfMonth },
|
||||
})
|
||||
|
||||
// Service statistics
|
||||
const totalServices = await Billing.countDocuments()
|
||||
const activeServices = await Billing.countDocuments({
|
||||
payment_status: 'paid',
|
||||
service_status: 1, // 1 = active
|
||||
})
|
||||
|
||||
// Developer requests
|
||||
const totalDeveloperRequests = await DeveloperRequest.countDocuments()
|
||||
const pendingDeveloperRequests = await DeveloperRequest.countDocuments({
|
||||
status: 'pending',
|
||||
})
|
||||
|
||||
// Recent activity (last 7 days)
|
||||
const recentUsers = await User.find({ createdAt: { $gte: startOfWeek } })
|
||||
.select('name email siliconId createdAt')
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(10)
|
||||
|
||||
const recentTransactions = await Transaction.find({ createdAt: { $gte: startOfWeek } })
|
||||
.populate('userId', 'name email siliconId')
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(10)
|
||||
|
||||
// Service breakdown
|
||||
const serviceBreakdown = await Billing.aggregate([
|
||||
{
|
||||
$group: {
|
||||
_id: '$service_type',
|
||||
count: { $sum: 1 },
|
||||
revenue: { $sum: '$amount' },
|
||||
},
|
||||
},
|
||||
{ $sort: { count: -1 } },
|
||||
])
|
||||
|
||||
// Monthly growth data (last 6 months)
|
||||
const sixMonthsAgo = new Date()
|
||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6)
|
||||
|
||||
const monthlyGrowth = await User.aggregate([
|
||||
{ $match: { createdAt: { $gte: sixMonthsAgo } } },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
year: { $year: '$createdAt' },
|
||||
month: { $month: '$createdAt' },
|
||||
},
|
||||
count: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{ $sort: { '_id.year': 1, '_id.month': 1 } },
|
||||
])
|
||||
|
||||
const revenueGrowth = await Billing.aggregate([
|
||||
{ $match: { created_at: { $gte: sixMonthsAgo } } },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
year: { $year: '$created_at' },
|
||||
month: { $month: '$created_at' },
|
||||
},
|
||||
revenue: { $sum: '$amount' },
|
||||
},
|
||||
},
|
||||
{ $sort: { '_id.year': 1, '_id.month': 1 } },
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
users: {
|
||||
total: totalUsers,
|
||||
active: activeUsers,
|
||||
newThisMonth: newUsersThisMonth,
|
||||
verified: verifiedUsers,
|
||||
verificationRate: totalUsers > 0 ? ((verifiedUsers / totalUsers) * 100).toFixed(1) : 0,
|
||||
},
|
||||
revenue: {
|
||||
total: totalRevenue[0]?.total || 0,
|
||||
monthly: monthlyRevenue[0]?.total || 0,
|
||||
weekly: weeklyRevenue[0]?.total || 0,
|
||||
},
|
||||
transactions: {
|
||||
total: totalTransactions,
|
||||
monthly: monthlyTransactions,
|
||||
},
|
||||
services: {
|
||||
total: totalServices,
|
||||
active: activeServices,
|
||||
breakdown: serviceBreakdown,
|
||||
},
|
||||
developerRequests: {
|
||||
total: totalDeveloperRequests,
|
||||
pending: pendingDeveloperRequests,
|
||||
},
|
||||
recentActivity: {
|
||||
users: recentUsers,
|
||||
transactions: recentTransactions,
|
||||
},
|
||||
growth: {
|
||||
users: monthlyGrowth,
|
||||
revenue: revenueGrowth,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Admin dashboard error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch dashboard data' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
}
|
||||
187
app/api/admin/reports/route.ts
Normal file
187
app/api/admin/reports/route.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { withAdminAuth } from '@/lib/admin-middleware'
|
||||
import { connectDB } from '@/lib/mongodb'
|
||||
import { User } from '@/models/user'
|
||||
import { Transaction } from '@/models/transaction'
|
||||
import { Billing } from '@/models/billing'
|
||||
import { DeveloperRequest } from '@/models/developer-request'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return withAdminAuth(request, async (req, admin) => {
|
||||
try {
|
||||
await connectDB()
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const reportType = searchParams.get('type')
|
||||
const format = searchParams.get('format') || 'json'
|
||||
const dateFrom = searchParams.get('dateFrom')
|
||||
const dateTo = searchParams.get('dateTo')
|
||||
|
||||
// Build date filter
|
||||
const dateFilter: any = {}
|
||||
if (dateFrom || dateTo) {
|
||||
dateFilter.createdAt = {}
|
||||
if (dateFrom) dateFilter.createdAt.$gte = new Date(dateFrom)
|
||||
if (dateTo) dateFilter.createdAt.$lte = new Date(dateTo)
|
||||
}
|
||||
|
||||
let data: any = {}
|
||||
|
||||
switch (reportType) {
|
||||
case 'users':
|
||||
data = await User.find(dateFilter)
|
||||
.select('-password -refreshToken')
|
||||
.sort({ createdAt: -1 })
|
||||
break
|
||||
|
||||
case 'transactions':
|
||||
data = await Transaction.find(dateFilter)
|
||||
.populate('userId', 'name email siliconId')
|
||||
.sort({ createdAt: -1 })
|
||||
break
|
||||
|
||||
case 'billing':
|
||||
data = await Billing.find(
|
||||
dateFilter.createdAt ? { created_at: dateFilter.createdAt } : {}
|
||||
).sort({ created_at: -1 })
|
||||
break
|
||||
|
||||
case 'developer-requests':
|
||||
const devFilter = dateFilter.createdAt ? { createdAt: dateFilter.createdAt } : {}
|
||||
data = await (DeveloperRequest as any)
|
||||
.find(devFilter)
|
||||
.populate('userId', 'name email siliconId')
|
||||
.sort({ createdAt: -1 })
|
||||
break
|
||||
|
||||
case 'summary':
|
||||
// Generate comprehensive summary report
|
||||
const [users, transactions, billings, developerRequests] = await Promise.all([
|
||||
User.find(dateFilter).select('-password -refreshToken'),
|
||||
Transaction.find(dateFilter).populate('userId', 'name email siliconId'),
|
||||
Billing.find(dateFilter.createdAt ? { created_at: dateFilter.createdAt } : {}),
|
||||
(DeveloperRequest as any)
|
||||
.find(dateFilter.createdAt ? { createdAt: dateFilter.createdAt } : {})
|
||||
.populate('userId', 'name email siliconId'),
|
||||
])
|
||||
|
||||
// Calculate summary statistics
|
||||
const userStats = {
|
||||
total: users.length,
|
||||
verified: users.filter((u) => u.isVerified).length,
|
||||
admins: users.filter((u) => u.role === 'admin').length,
|
||||
totalBalance: users.reduce((sum, u) => sum + (u.balance || 0), 0),
|
||||
}
|
||||
|
||||
const transactionStats = {
|
||||
total: transactions.length,
|
||||
totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0),
|
||||
credits: transactions.filter((t) => t.type === 'credit').length,
|
||||
debits: transactions.filter((t) => t.type === 'debit').length,
|
||||
}
|
||||
|
||||
const billingStats = {
|
||||
total: billings.length,
|
||||
totalRevenue: billings.reduce((sum, b) => sum + b.amount, 0),
|
||||
completed: billings.filter((b) => b.payment_status === 'paid').length,
|
||||
pending: billings.filter((b) => b.payment_status === 'pending').length,
|
||||
}
|
||||
|
||||
const developerStats = {
|
||||
total: developerRequests.length,
|
||||
pending: developerRequests.filter((d) => d.status === 'pending').length,
|
||||
inProgress: developerRequests.filter((d) => d.status === 'in_progress').length,
|
||||
completed: developerRequests.filter((d) => d.status === 'completed').length,
|
||||
}
|
||||
|
||||
data = {
|
||||
summary: {
|
||||
users: userStats,
|
||||
transactions: transactionStats,
|
||||
billing: billingStats,
|
||||
developerRequests: developerStats,
|
||||
},
|
||||
users: users.slice(0, 100), // Limit for performance
|
||||
transactions: transactions.slice(0, 100),
|
||||
billings: billings.slice(0, 100),
|
||||
developerRequests: developerRequests.slice(0, 100),
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: 'Invalid report type' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (format === 'csv') {
|
||||
// Convert to CSV format
|
||||
const csv = convertToCSV(data, reportType)
|
||||
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-Disposition': `attachment; filename="${reportType}_report_${new Date().toISOString().split('T')[0]}.csv"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
reportType,
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedBy: admin.name,
|
||||
dateRange: { from: dateFrom, to: dateTo },
|
||||
data,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Admin reports error:', error)
|
||||
return NextResponse.json({ error: 'Failed to generate report' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function convertToCSV(data: any, reportType: string): string {
|
||||
if (!Array.isArray(data)) {
|
||||
if (reportType === 'summary' && data.summary) {
|
||||
// For summary reports, create a simple CSV with key metrics
|
||||
const lines = [
|
||||
'Metric,Value',
|
||||
`Total Users,${data.summary.users.total}`,
|
||||
`Verified Users,${data.summary.users.verified}`,
|
||||
`Admin Users,${data.summary.users.admins}`,
|
||||
`Total Balance,${data.summary.users.totalBalance}`,
|
||||
`Total Transactions,${data.summary.transactions.total}`,
|
||||
`Transaction Amount,${data.summary.transactions.totalAmount}`,
|
||||
`Total Billing Records,${data.summary.billing.total}`,
|
||||
`Total Revenue,${data.summary.billing.totalRevenue}`,
|
||||
`Developer Requests,${data.summary.developerRequests.total}`,
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
return 'No data available'
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return 'No data available'
|
||||
}
|
||||
|
||||
// Get headers from first object
|
||||
const headers = Object.keys(data[0]).filter(
|
||||
(key) => typeof data[0][key] !== 'object' || data[0][key] === null
|
||||
)
|
||||
|
||||
// Create CSV content
|
||||
const csvHeaders = headers.join(',')
|
||||
const csvRows = data.map((row) =>
|
||||
headers
|
||||
.map((header) => {
|
||||
const value = row[header]
|
||||
if (value === null || value === undefined) return ''
|
||||
if (typeof value === 'string' && value.includes(',')) {
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
return value
|
||||
})
|
||||
.join(',')
|
||||
)
|
||||
|
||||
return [csvHeaders, ...csvRows].join('\n')
|
||||
}
|
||||
200
app/api/admin/services/route.ts
Normal file
200
app/api/admin/services/route.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { withAdminAuth } from '@/lib/admin-middleware'
|
||||
import { connectDB } from '@/lib/mongodb'
|
||||
import { Billing } from '@/models/billing'
|
||||
import { DeveloperRequest } from '@/models/developer-request'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return withAdminAuth(request, async (req, admin) => {
|
||||
try {
|
||||
await connectDB()
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const serviceType = searchParams.get('serviceType') || ''
|
||||
const status = searchParams.get('status') || ''
|
||||
const dateFrom = searchParams.get('dateFrom')
|
||||
const dateTo = searchParams.get('dateTo')
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
// Build filter query for billing services
|
||||
const billingFilter: any = {}
|
||||
|
||||
if (serviceType && serviceType !== 'all') {
|
||||
billingFilter.service_type = serviceType
|
||||
}
|
||||
|
||||
if (status && status !== 'all') {
|
||||
billingFilter.service_status = status
|
||||
}
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
billingFilter.created_at = {}
|
||||
if (dateFrom) billingFilter.created_at.$gte = new Date(dateFrom)
|
||||
if (dateTo) billingFilter.created_at.$lte = new Date(dateTo)
|
||||
}
|
||||
|
||||
// Fetch billing services
|
||||
const [billingServices, totalBillingServices] = await Promise.all([
|
||||
Billing.find(billingFilter).sort({ created_at: -1 }).skip(skip).limit(limit),
|
||||
Billing.countDocuments(billingFilter),
|
||||
])
|
||||
|
||||
// Build filter for developer requests
|
||||
const devRequestFilter: any = {}
|
||||
|
||||
if (status && status !== 'all') {
|
||||
devRequestFilter.status = status
|
||||
}
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
devRequestFilter.createdAt = {}
|
||||
if (dateFrom) devRequestFilter.createdAt.$gte = new Date(dateFrom)
|
||||
if (dateTo) devRequestFilter.createdAt.$lte = new Date(dateTo)
|
||||
}
|
||||
|
||||
// Fetch developer requests
|
||||
const [developerRequests, totalDeveloperRequests] = await Promise.all([
|
||||
(DeveloperRequest as any)
|
||||
.find(devRequestFilter)
|
||||
.populate('userId', 'name email siliconId')
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit),
|
||||
(DeveloperRequest as any).countDocuments(devRequestFilter),
|
||||
])
|
||||
|
||||
// Service statistics
|
||||
const serviceStats = await Billing.aggregate([
|
||||
{
|
||||
$group: {
|
||||
_id: '$service_type',
|
||||
count: { $sum: 1 },
|
||||
revenue: { $sum: '$amount' },
|
||||
activeServices: {
|
||||
$sum: { $cond: [{ $eq: ['$service_status', 'active'] }, 1, 0] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ $sort: { count: -1 } },
|
||||
])
|
||||
|
||||
// Status breakdown
|
||||
const statusBreakdown = await Billing.aggregate([
|
||||
{
|
||||
$group: {
|
||||
_id: '$service_status',
|
||||
count: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const totalPages = Math.ceil(Math.max(totalBillingServices, totalDeveloperRequests) / limit)
|
||||
|
||||
return NextResponse.json({
|
||||
billingServices,
|
||||
developerRequests,
|
||||
serviceStats,
|
||||
statusBreakdown,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
totalPages,
|
||||
totalBillingServices,
|
||||
totalDeveloperRequests,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Admin services fetch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch services data' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return withAdminAuth(request, async (req, admin) => {
|
||||
try {
|
||||
await connectDB()
|
||||
|
||||
const body = await request.json()
|
||||
const { action, serviceId, serviceType, data } = body
|
||||
|
||||
if (action === 'updateBilling') {
|
||||
const billing = await Billing.findByIdAndUpdate(
|
||||
serviceId,
|
||||
{
|
||||
service_status: data.service_status,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
{ new: true }
|
||||
)
|
||||
|
||||
if (!billing) {
|
||||
return NextResponse.json({ error: 'Service not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ service: billing })
|
||||
}
|
||||
|
||||
if (action === 'updateDeveloperRequest') {
|
||||
const developerRequest = await (DeveloperRequest as any)
|
||||
.findByIdAndUpdate(
|
||||
serviceId,
|
||||
{
|
||||
status: data.status,
|
||||
assignedDeveloper: data.assignedDeveloper,
|
||||
estimatedCompletionDate: data.estimatedCompletionDate,
|
||||
notes: data.notes,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{ new: true }
|
||||
)
|
||||
.populate('userId', 'name email siliconId')
|
||||
|
||||
if (!developerRequest) {
|
||||
return NextResponse.json({ error: 'Developer request not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ service: developerRequest })
|
||||
}
|
||||
|
||||
if (action === 'cancelService') {
|
||||
if (serviceType === 'billing') {
|
||||
const billing = await Billing.findByIdAndUpdate(
|
||||
serviceId,
|
||||
{
|
||||
service_status: 'cancelled',
|
||||
updated_at: new Date(),
|
||||
},
|
||||
{ new: true }
|
||||
)
|
||||
|
||||
return NextResponse.json({ service: billing })
|
||||
}
|
||||
|
||||
if (serviceType === 'developer') {
|
||||
const updatedDeveloperRequest = await (DeveloperRequest as any)
|
||||
.findByIdAndUpdate(
|
||||
serviceId,
|
||||
{
|
||||
status: 'cancelled',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{ new: true }
|
||||
)
|
||||
.populate('userId', 'name email siliconId')
|
||||
|
||||
return NextResponse.json({ service: updatedDeveloperRequest })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid action or service type' }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error('Admin service action error:', error)
|
||||
return NextResponse.json({ error: 'Failed to perform service action' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
}
|
||||
117
app/api/admin/settings/route.ts
Normal file
117
app/api/admin/settings/route.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { withAdminAuth } from '@/lib/admin-middleware'
|
||||
import { connectDB } from '@/lib/mongodb'
|
||||
import { User } from '@/models/user'
|
||||
import { z } from 'zod'
|
||||
import { getSystemSettings, updateSystemSettings } from '@/lib/system-settings'
|
||||
|
||||
const SystemSettingsSchema = z.object({
|
||||
maintenanceMode: z.boolean().optional(),
|
||||
registrationEnabled: z.boolean().optional(),
|
||||
emailVerificationRequired: z.boolean().optional(),
|
||||
maxUserBalance: z.number().min(0).optional(),
|
||||
defaultUserRole: z.enum(['user', 'admin']).optional(),
|
||||
systemMessage: z.string().optional(),
|
||||
paymentGatewayEnabled: z.boolean().optional(),
|
||||
developerHireEnabled: z.boolean().optional(),
|
||||
vpsDeploymentEnabled: z.boolean().optional(),
|
||||
kubernetesDeploymentEnabled: z.boolean().optional(),
|
||||
vpnServiceEnabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
// System settings are now managed by the system-settings service
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return withAdminAuth(request, async (req, admin) => {
|
||||
try {
|
||||
await connectDB()
|
||||
|
||||
// Get system statistics
|
||||
const totalUsers = await User.countDocuments()
|
||||
const adminUsers = await User.countDocuments({ role: 'admin' })
|
||||
const verifiedUsers = await User.countDocuments({ isVerified: true })
|
||||
const unverifiedUsers = await User.countDocuments({ isVerified: false })
|
||||
|
||||
// Get recent admin activities (mock data for now)
|
||||
const recentActivities = [
|
||||
{
|
||||
id: '1',
|
||||
action: 'User role updated',
|
||||
details: 'Changed user role from user to admin',
|
||||
timestamp: new Date().toISOString(),
|
||||
adminName: admin.name,
|
||||
},
|
||||
]
|
||||
|
||||
return NextResponse.json({
|
||||
settings: await getSystemSettings(),
|
||||
statistics: {
|
||||
totalUsers,
|
||||
adminUsers,
|
||||
verifiedUsers,
|
||||
unverifiedUsers,
|
||||
},
|
||||
recentActivities,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Admin settings fetch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch system settings' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return withAdminAuth(request, async (req, admin) => {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { action, settings } = body
|
||||
|
||||
if (action === 'updateSettings') {
|
||||
const validatedSettings = SystemSettingsSchema.parse(settings)
|
||||
|
||||
// Update system settings
|
||||
const updatedSettings = {
|
||||
...validatedSettings,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
updatedBy: admin.name,
|
||||
}
|
||||
await updateSystemSettings(updatedSettings)
|
||||
|
||||
return NextResponse.json({
|
||||
settings: await getSystemSettings(),
|
||||
message: 'Settings updated successfully',
|
||||
})
|
||||
}
|
||||
|
||||
if (action === 'clearCache') {
|
||||
// Mock cache clearing - in a real app, this would clear Redis/memory cache
|
||||
return NextResponse.json({
|
||||
message: 'System cache cleared successfully',
|
||||
})
|
||||
}
|
||||
|
||||
if (action === 'backupDatabase') {
|
||||
// Mock database backup - in a real app, this would trigger a backup process
|
||||
return NextResponse.json({
|
||||
message: 'Database backup initiated successfully',
|
||||
backupId: `backup_${Date.now()}`,
|
||||
})
|
||||
}
|
||||
|
||||
if (action === 'sendSystemNotification') {
|
||||
const { message, targetUsers } = body
|
||||
|
||||
// Mock system notification - in a real app, this would send notifications
|
||||
return NextResponse.json({
|
||||
message: `System notification sent to ${targetUsers} users`,
|
||||
notificationId: `notif_${Date.now()}`,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error('Admin settings update error:', error)
|
||||
return NextResponse.json({ error: 'Failed to update system settings' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
}
|
||||
163
app/api/admin/users/route.ts
Normal file
163
app/api/admin/users/route.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { withAdminAuth } from '@/lib/admin-middleware'
|
||||
import { connectDB } from '@/lib/mongodb'
|
||||
import { User } from '@/models/user'
|
||||
import { z } from 'zod'
|
||||
|
||||
const UserUpdateSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
email: z.string().email().optional(),
|
||||
role: z.enum(['user', 'admin']).optional(),
|
||||
isVerified: z.boolean().optional(),
|
||||
balance: z.number().min(0).optional(),
|
||||
})
|
||||
|
||||
const UserCreateSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
role: z.enum(['user', 'admin']).default('user'),
|
||||
isVerified: z.boolean().default(false),
|
||||
balance: z.number().min(0).default(0),
|
||||
})
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return withAdminAuth(request, async (req, admin) => {
|
||||
try {
|
||||
await connectDB()
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const search = searchParams.get('search') || ''
|
||||
const role = searchParams.get('role') || ''
|
||||
const verified = searchParams.get('verified') || ''
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
// Build filter query
|
||||
const filter: any = {}
|
||||
|
||||
if (search) {
|
||||
filter.$or = [
|
||||
{ name: { $regex: search, $options: 'i' } },
|
||||
{ email: { $regex: search, $options: 'i' } },
|
||||
{ siliconId: { $regex: search, $options: 'i' } },
|
||||
]
|
||||
}
|
||||
|
||||
if (role && role !== 'all') {
|
||||
filter.role = role
|
||||
}
|
||||
|
||||
if (verified && verified !== 'all') {
|
||||
filter.isVerified = verified === 'true'
|
||||
}
|
||||
|
||||
const [users, totalUsers] = await Promise.all([
|
||||
User.find(filter)
|
||||
.select('-password -refreshToken')
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit),
|
||||
User.countDocuments(filter),
|
||||
])
|
||||
|
||||
const totalPages = Math.ceil(totalUsers / limit)
|
||||
|
||||
return NextResponse.json({
|
||||
users,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
totalPages,
|
||||
totalUsers,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Admin users fetch error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch users' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return withAdminAuth(request, async (req, admin) => {
|
||||
try {
|
||||
await connectDB()
|
||||
|
||||
const body = await request.json()
|
||||
const { action, userId, data } = body
|
||||
|
||||
if (action === 'update') {
|
||||
const validatedData = UserUpdateSchema.parse(data)
|
||||
|
||||
const user = await User.findByIdAndUpdate(userId, validatedData, {
|
||||
new: true,
|
||||
runValidators: true,
|
||||
}).select('-password -refreshToken')
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ user })
|
||||
}
|
||||
|
||||
if (action === 'create') {
|
||||
const validatedData = UserCreateSchema.parse(data)
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await User.findOne({ email: validatedData.email })
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User with this email already exists' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate unique Silicon ID
|
||||
const generateSiliconId = () => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
let result = 'SP'
|
||||
for (let i = 0; i < 8; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
let siliconId = generateSiliconId()
|
||||
while (await User.findOne({ siliconId })) {
|
||||
siliconId = generateSiliconId()
|
||||
}
|
||||
|
||||
const user = new User({
|
||||
...validatedData,
|
||||
siliconId,
|
||||
provider: 'local',
|
||||
})
|
||||
|
||||
await user.save()
|
||||
|
||||
const userResponse = await User.findById(user._id).select('-password -refreshToken')
|
||||
return NextResponse.json({ user: userResponse })
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
const user = await User.findByIdAndDelete(userId)
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'User deleted successfully' })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error('Admin user action error:', error)
|
||||
return NextResponse.json({ error: 'Failed to perform user action' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
}
|
||||
120
app/api/auth/forgot-password/route.ts
Normal file
120
app/api/auth/forgot-password/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
|
||||
const ForgotPasswordSchema = z.object({
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate the request body
|
||||
const validatedData = ForgotPasswordSchema.parse(body)
|
||||
|
||||
// Simulate processing delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// Dummy API - Always return an error for demonstration
|
||||
// You can change this behavior for testing different scenarios
|
||||
|
||||
const email = validatedData.email
|
||||
|
||||
// Simulate different error scenarios based on email
|
||||
if (email === 'test@example.com') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Email address not found in our system',
|
||||
code: 'EMAIL_NOT_FOUND'
|
||||
}
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (email.includes('blocked')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'This email address has been temporarily blocked',
|
||||
code: 'EMAIL_BLOCKED'
|
||||
}
|
||||
},
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
if (email.includes('invalid')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Invalid email format',
|
||||
code: 'INVALID_EMAIL'
|
||||
}
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Default error response (500 Internal Server Error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Unable to process password reset request at this time. Please try again later.',
|
||||
code: 'SERVER_ERROR'
|
||||
}
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
// Uncomment below for success response (when you want to test success state)
|
||||
/*
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Password reset email sent successfully',
|
||||
data: {
|
||||
email: validatedData.email,
|
||||
resetTokenExpiry: Date.now() + 3600000 // 1 hour from now
|
||||
}
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
*/
|
||||
|
||||
} catch (error) {
|
||||
console.error('Forgot password API error:', error)
|
||||
|
||||
// Handle validation errors
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Invalid request data',
|
||||
code: 'VALIDATION_ERROR',
|
||||
details: error.issues
|
||||
}
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'An unexpected error occurred',
|
||||
code: 'INTERNAL_ERROR'
|
||||
}
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
113
app/api/auth/google/callback/route.ts
Normal file
113
app/api/auth/google/callback/route.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getGoogleUser } from '@/lib/google-oauth'
|
||||
import { generateTokens } from '@/lib/jwt'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import { User } from '@/models/user'
|
||||
import { appConfig } from '@/lib/env'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
const error = searchParams.get('error')
|
||||
|
||||
// Handle OAuth errors
|
||||
if (error) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/auth?error=${encodeURIComponent(error)}`, appConfig.appUrl)
|
||||
)
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!code || state !== 'google_oauth') {
|
||||
return NextResponse.redirect(new URL('/auth?error=invalid_oauth_callback', appConfig.appUrl))
|
||||
}
|
||||
|
||||
// Get user info from Google
|
||||
const googleUser = await getGoogleUser(code)
|
||||
|
||||
// Connect to database
|
||||
await connectDB()
|
||||
|
||||
// Check if user exists
|
||||
let user = await User.findOne({
|
||||
$or: [
|
||||
{ email: googleUser.email.toLowerCase() },
|
||||
{ providerId: googleUser.id, provider: 'google' },
|
||||
],
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
// Create new user
|
||||
user = new User({
|
||||
email: googleUser.email.toLowerCase(),
|
||||
name: googleUser.name,
|
||||
provider: 'google',
|
||||
providerId: googleUser.id,
|
||||
avatar: googleUser.picture || undefined,
|
||||
isVerified: googleUser.verified_email,
|
||||
lastLogin: new Date(),
|
||||
})
|
||||
} else {
|
||||
// Update existing user with Google info (preserve original provider)
|
||||
// Only link Google if user doesn't already have a local account
|
||||
if (user.provider !== 'local') {
|
||||
user.provider = 'google'
|
||||
user.providerId = googleUser.id
|
||||
} else {
|
||||
// For local users, just add Google info without changing provider
|
||||
if (!user.providerId) {
|
||||
user.providerId = googleUser.id
|
||||
}
|
||||
}
|
||||
|
||||
// Update other info safely
|
||||
user.name = googleUser.name
|
||||
user.isVerified = googleUser.verified_email || user.isVerified
|
||||
user.lastLogin = new Date()
|
||||
|
||||
// Update avatar only if user doesn't have one
|
||||
if (googleUser.picture && !user.avatar) {
|
||||
user.avatar = googleUser.picture
|
||||
}
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const { accessToken, refreshToken } = generateTokens({
|
||||
userId: user._id.toString(),
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
})
|
||||
|
||||
// Update user's refresh token
|
||||
user.refreshToken = refreshToken
|
||||
await user.save()
|
||||
|
||||
// Create redirect response using the public app URL
|
||||
const redirectURL = new URL('/dashboard', appConfig.appUrl)
|
||||
const response = NextResponse.redirect(redirectURL)
|
||||
|
||||
// Set HTTP-only cookies
|
||||
response.cookies.set('accessToken', accessToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 15 * 60, // 15 minutes
|
||||
path: '/',
|
||||
})
|
||||
|
||||
response.cookies.set('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Google OAuth callback error:', error)
|
||||
return NextResponse.redirect(new URL('/auth?error=oauth_callback_failed', appConfig.appUrl))
|
||||
}
|
||||
}
|
||||
22
app/api/auth/google/route.ts
Normal file
22
app/api/auth/google/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getGoogleAuthURL } from '@/lib/google-oauth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authURL = getGoogleAuthURL()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { authURL },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Google OAuth URL generation error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to generate Google auth URL', code: 'OAUTH_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
98
app/api/auth/login/route.ts
Normal file
98
app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import { User } from '@/models/user'
|
||||
import { generateTokens } from '@/lib/jwt'
|
||||
|
||||
const LoginSchema = z.object({
|
||||
emailOrId: z.string().min(1, 'Email or Silicon ID is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
rememberMe: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate input
|
||||
const validatedData = LoginSchema.parse(body)
|
||||
|
||||
// Connect to database
|
||||
await connectDB()
|
||||
|
||||
// Find user by email or Silicon ID
|
||||
const emailOrId = validatedData.emailOrId
|
||||
const user = await User.findOne({
|
||||
$or: [{ email: emailOrId.toLowerCase() }, { siliconId: emailOrId }],
|
||||
})
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Invalid credentials', code: 'INVALID_CREDENTIALS' } },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check password
|
||||
const isPasswordValid = await user.comparePassword(validatedData.password)
|
||||
if (!isPasswordValid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Invalid credentials', code: 'INVALID_CREDENTIALS' } },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const { accessToken, refreshToken } = generateTokens({
|
||||
userId: user._id.toString(),
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
})
|
||||
|
||||
// Update user's refresh token and last login
|
||||
user.refreshToken = refreshToken
|
||||
user.lastLogin = new Date()
|
||||
await user.save()
|
||||
|
||||
// Create response with tokens
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
user: user.toJSON(),
|
||||
accessToken,
|
||||
},
|
||||
})
|
||||
|
||||
// Set HTTP-only cookies
|
||||
response.cookies.set('accessToken', accessToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 15 * 60, // 15 minutes
|
||||
path: '/',
|
||||
})
|
||||
|
||||
response.cookies.set('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: error.issues[0].message, code: 'VALIDATION_ERROR' } },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Internal server error', code: 'INTERNAL_ERROR' } },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
62
app/api/auth/logout/route.ts
Normal file
62
app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import { User } from '@/models/user'
|
||||
import { verifyRefreshToken } from '@/lib/jwt'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Get refresh token from cookie
|
||||
const refreshToken = request.cookies.get('refreshToken')?.value
|
||||
|
||||
if (refreshToken) {
|
||||
// Verify and decode the refresh token to get user ID
|
||||
const payload = verifyRefreshToken(refreshToken)
|
||||
|
||||
if (payload) {
|
||||
// Connect to database and remove refresh token
|
||||
await connectDB()
|
||||
await User.findByIdAndUpdate(payload.userId, {
|
||||
$unset: { refreshToken: 1 },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create response
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
data: { message: 'Logged out successfully' },
|
||||
})
|
||||
|
||||
// Clear cookies
|
||||
response.cookies.set('accessToken', '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
response.cookies.set('refreshToken', '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
|
||||
// Even if there's an error, we should still clear the cookies
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
data: { message: 'Logged out successfully' },
|
||||
})
|
||||
|
||||
response.cookies.set('accessToken', '', { maxAge: 0, path: '/' })
|
||||
response.cookies.set('refreshToken', '', { maxAge: 0, path: '/' })
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
33
app/api/auth/me/route.ts
Normal file
33
app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { withAuth } from '@/lib/auth-middleware'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import { User } from '@/models/user'
|
||||
|
||||
export const GET = withAuth(async (request: NextRequest & { user?: any }) => {
|
||||
try {
|
||||
// Connect to database
|
||||
await connectDB()
|
||||
|
||||
// Get user details
|
||||
const user = await User.findById(request.user.userId).select('-password -refreshToken')
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'User not found', code: 'USER_NOT_FOUND' } },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { user: user.toJSON() },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get user info error:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Internal server error', code: 'INTERNAL_ERROR' } },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
99
app/api/auth/refresh/route.ts
Normal file
99
app/api/auth/refresh/route.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import { User } from '@/models/user'
|
||||
import { verifyRefreshToken, generateTokens } from '@/lib/jwt'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Get refresh token from cookie
|
||||
const refreshToken = request.cookies.get('refreshToken')?.value
|
||||
|
||||
if (!refreshToken) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'No refresh token provided', code: 'NO_REFRESH_TOKEN' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify refresh token
|
||||
const payload = verifyRefreshToken(refreshToken)
|
||||
if (!payload) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Invalid refresh token', code: 'INVALID_REFRESH_TOKEN' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Connect to database and find user
|
||||
await connectDB()
|
||||
const user = await User.findById(payload.userId)
|
||||
|
||||
// Check if user exists and refresh token matches
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'User not found', code: 'USER_NOT_FOUND' } },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the stored refresh token matches (both are JWT tokens, so direct comparison is valid)
|
||||
if (user.refreshToken !== refreshToken) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Refresh token mismatch', code: 'TOKEN_MISMATCH' } },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate new tokens
|
||||
const { accessToken, refreshToken: newRefreshToken } = generateTokens({
|
||||
userId: user._id.toString(),
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
})
|
||||
|
||||
// Update user's refresh token
|
||||
user.refreshToken = newRefreshToken
|
||||
await user.save()
|
||||
|
||||
// Create response with new tokens
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken,
|
||||
user: user.toJSON(),
|
||||
},
|
||||
})
|
||||
|
||||
// Set new cookies
|
||||
response.cookies.set('accessToken', accessToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 15 * 60, // 15 minutes
|
||||
path: '/',
|
||||
})
|
||||
|
||||
response.cookies.set('refreshToken', newRefreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Token refresh error:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Internal server error', code: 'INTERNAL_ERROR' } },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
109
app/api/auth/register/route.ts
Normal file
109
app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import { User, UserSchema } from '@/models/user'
|
||||
import { generateTokens } from '@/lib/jwt'
|
||||
import { generateSiliconId } from '@/lib/siliconId'
|
||||
|
||||
const RegisterSchema = UserSchema.pick({
|
||||
email: true,
|
||||
name: true,
|
||||
password: true,
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate input
|
||||
const validatedData = RegisterSchema.parse(body)
|
||||
|
||||
// Connect to database
|
||||
await connectDB()
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await User.findOne({ email: validatedData.email.toLowerCase() })
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'User already exists', code: 'USER_EXISTS' } },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate tokens first
|
||||
const tempUser = {
|
||||
email: validatedData.email.toLowerCase(),
|
||||
name: validatedData.name,
|
||||
role: 'user' as const,
|
||||
}
|
||||
|
||||
// Generate unique Silicon ID
|
||||
const siliconId = generateSiliconId()
|
||||
console.log('Generated siliconId:', siliconId)
|
||||
|
||||
// Create new user
|
||||
const user = new User({
|
||||
email: tempUser.email,
|
||||
name: tempUser.name,
|
||||
password: validatedData.password,
|
||||
siliconId: siliconId,
|
||||
provider: 'local',
|
||||
})
|
||||
console.log('User before save:', JSON.stringify(user.toObject(), null, 2))
|
||||
await user.save()
|
||||
console.log('user after save:', user)
|
||||
|
||||
// Generate tokens with actual user ID
|
||||
const { accessToken, refreshToken } = generateTokens({
|
||||
userId: user._id.toString(),
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
})
|
||||
|
||||
// Update user with refresh token (single save)
|
||||
user.refreshToken = refreshToken
|
||||
await user.save()
|
||||
|
||||
// Create response with tokens
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
user: user.toJSON(),
|
||||
accessToken,
|
||||
},
|
||||
})
|
||||
|
||||
// Set HTTP-only cookies
|
||||
response.cookies.set('accessToken', accessToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 15 * 60, // 15 minutes
|
||||
path: '/',
|
||||
})
|
||||
|
||||
response.cookies.set('refreshToken', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: error.issues[0].message, code: 'VALIDATION_ERROR' } },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Internal server error', code: 'INTERNAL_ERROR' } },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
237
app/api/balance/add/route.ts
Normal file
237
app/api/balance/add/route.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import crypto from 'crypto'
|
||||
|
||||
interface AddBalanceRequest {
|
||||
amount: number
|
||||
currency: 'INR' | 'USD'
|
||||
}
|
||||
|
||||
interface UserTokenPayload {
|
||||
siliconId: string
|
||||
email: string
|
||||
type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Balance API
|
||||
* Initiates "Add Balance" transaction for user accounts
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Verify user authentication
|
||||
const token = request.cookies.get('accessToken')?.value
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const secret = process.env.JWT_SECRET || 'your-secret-key'
|
||||
const user = jwt.verify(token, secret) as UserTokenPayload
|
||||
|
||||
// Parse request body
|
||||
const body: AddBalanceRequest = await request.json()
|
||||
const { amount, currency } = body
|
||||
|
||||
// Validate input
|
||||
if (!amount || amount <= 0 || !['INR', 'USD'].includes(currency)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Invalid amount or currency' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate amount limits
|
||||
const minAmount = currency === 'INR' ? 100 : 2 // Minimum ₹100 or $2
|
||||
const maxAmount = currency === 'INR' ? 100000 : 1500 // Maximum ₹1,00,000 or $1500
|
||||
|
||||
if (amount < minAmount || amount > maxAmount) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Amount must be between ${currency === 'INR' ? '₹' : '$'}${minAmount} and ${currency === 'INR' ? '₹' : '$'}${maxAmount}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate unique transaction ID
|
||||
const transactionId = generateTransactionId()
|
||||
|
||||
// TODO: Save transaction to database
|
||||
// In real implementation:
|
||||
// await saveAddBalanceTransaction({
|
||||
// siliconId: user.siliconId,
|
||||
// amount,
|
||||
// currency,
|
||||
// transaction_id: transactionId,
|
||||
// status: 'pending'
|
||||
// })
|
||||
|
||||
// Mock database save for demo
|
||||
await mockSaveAddBalanceTransaction(user.siliconId, amount, currency, transactionId)
|
||||
|
||||
// PayU configuration
|
||||
const merchantKey = process.env.PAYU_MERCHANT_KEY || 'test-key'
|
||||
const merchantSalt = process.env.PAYU_MERCHANT_SALT || 'test-salt'
|
||||
const payuUrl = process.env.PAYU_URL || 'https://test.payu.in/_payment'
|
||||
|
||||
// Prepare payment data
|
||||
const productinfo = 'add_balance'
|
||||
const firstname = 'Customer'
|
||||
const email = user.email
|
||||
const phone = '9876543210' // Default phone or fetch from user profile
|
||||
|
||||
// Success and failure URLs for balance transactions
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:4023'
|
||||
const surl = `${baseUrl}/api/balance/success`
|
||||
const furl = `${baseUrl}/api/balance/failure`
|
||||
|
||||
// Generate PayU hash
|
||||
const hashString = `${merchantKey}|${transactionId}|${amount}|${productinfo}|${firstname}|${email}|||||||||||${merchantSalt}`
|
||||
const hash = crypto.createHash('sha512').update(hashString).digest('hex')
|
||||
|
||||
// Return payment form data for frontend submission
|
||||
const paymentData = {
|
||||
success: true,
|
||||
transaction_id: transactionId,
|
||||
currency,
|
||||
payment_url: payuUrl,
|
||||
form_data: {
|
||||
key: merchantKey,
|
||||
txnid: transactionId,
|
||||
amount: amount.toFixed(2),
|
||||
productinfo,
|
||||
firstname,
|
||||
email,
|
||||
phone,
|
||||
surl,
|
||||
furl,
|
||||
hash,
|
||||
service_provider: 'payu_paisa',
|
||||
},
|
||||
}
|
||||
|
||||
return NextResponse.json(paymentData)
|
||||
} catch (dbError) {
|
||||
console.error('Database error during add balance:', dbError)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Failed to initiate balance transaction' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Add balance error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Add balance initiation failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Add Balance History
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Verify user authentication
|
||||
const token = request.cookies.get('accessToken')?.value
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const secret = process.env.JWT_SECRET || 'your-secret-key'
|
||||
const user = jwt.verify(token, secret) as UserTokenPayload
|
||||
|
||||
// TODO: Fetch balance history from database
|
||||
// In real implementation:
|
||||
// const history = await getBalanceHistory(user.siliconId)
|
||||
|
||||
// Mock balance history for demo
|
||||
const mockHistory = await getMockBalanceHistory(user.siliconId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
balance_history: mockHistory,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get balance history error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Failed to fetch balance history' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function generateTransactionId(): string {
|
||||
const timestamp = Date.now().toString(36)
|
||||
const random = Math.random().toString(36).substr(2, 5)
|
||||
return `bal_${timestamp}${random}`.toUpperCase()
|
||||
}
|
||||
|
||||
// Mock functions for demonstration
|
||||
async function mockSaveAddBalanceTransaction(
|
||||
siliconId: string,
|
||||
amount: number,
|
||||
currency: string,
|
||||
transactionId: string
|
||||
) {
|
||||
console.log(`Mock DB Save: Add Balance Transaction`)
|
||||
console.log(`SiliconID: ${siliconId}, Amount: ${amount} ${currency}, TxnID: ${transactionId}`)
|
||||
|
||||
// Mock database record
|
||||
const transaction = {
|
||||
id: Date.now(),
|
||||
silicon_id: siliconId,
|
||||
transaction_id: transactionId,
|
||||
amount,
|
||||
currency,
|
||||
status: 'pending',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}
|
||||
|
||||
// Mock delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
return transaction
|
||||
}
|
||||
|
||||
async function getMockBalanceHistory(siliconId: string) {
|
||||
// Mock balance transaction history
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
transaction_id: 'BAL_123ABC',
|
||||
amount: 1000,
|
||||
currency: 'INR',
|
||||
status: 'success',
|
||||
created_at: '2025-01-15T10:30:00Z',
|
||||
updated_at: '2025-01-15T10:31:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
transaction_id: 'BAL_456DEF',
|
||||
amount: 500,
|
||||
currency: 'INR',
|
||||
status: 'failed',
|
||||
created_at: '2025-01-10T14:20:00Z',
|
||||
updated_at: '2025-01-10T14:21:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
transaction_id: 'BAL_789GHI',
|
||||
amount: 2000,
|
||||
currency: 'INR',
|
||||
status: 'success',
|
||||
created_at: '2025-01-05T09:15:00Z',
|
||||
updated_at: '2025-01-05T09:16:00Z',
|
||||
},
|
||||
]
|
||||
}
|
||||
169
app/api/balance/deduct/route.ts
Normal file
169
app/api/balance/deduct/route.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import { User as UserModel } from '@/models/user'
|
||||
import { Transaction } from '@/models/transaction'
|
||||
|
||||
// Schema for balance deduction request
|
||||
const DeductBalanceSchema = z.object({
|
||||
amount: z.number().positive('Amount must be positive'),
|
||||
service: z.string().min(1, 'Service name is required'),
|
||||
serviceId: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
transactionId: z.string().optional(),
|
||||
})
|
||||
|
||||
// Schema for balance deduction response
|
||||
const DeductBalanceResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
transactionId: z.string(),
|
||||
previousBalance: z.number(),
|
||||
newBalance: z.number(),
|
||||
amountDeducted: z.number(),
|
||||
service: z.string(),
|
||||
timestamp: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
error: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
code: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Authentication required', code: 'UNAUTHORIZED' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
await connectDB()
|
||||
|
||||
// Parse and validate request body
|
||||
const body = await request.json()
|
||||
const validatedData = DeductBalanceSchema.parse(body)
|
||||
|
||||
// Get user's current balance from database
|
||||
const userData = await UserModel.findOne({ email: user.email })
|
||||
if (!userData) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'User not found', code: 'USER_NOT_FOUND' },
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const currentBalance = userData.balance || 0
|
||||
|
||||
// Check if user has sufficient balance
|
||||
if (currentBalance < validatedData.amount) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: `Insufficient balance. Required: ₹${validatedData.amount}, Available: ₹${currentBalance}`,
|
||||
code: 'INSUFFICIENT_BALANCE',
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate new balance
|
||||
const newBalance = currentBalance - validatedData.amount
|
||||
|
||||
// Update user balance in database
|
||||
await UserModel.updateOne(
|
||||
{ email: user.email },
|
||||
{
|
||||
$set: { balance: newBalance },
|
||||
$inc: { __v: 1 }, // Increment version for optimistic locking
|
||||
}
|
||||
)
|
||||
|
||||
// Generate transaction ID if not provided
|
||||
const transactionId =
|
||||
validatedData.transactionId || `txn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// Save transaction record to database
|
||||
try {
|
||||
const newTransaction = new Transaction({
|
||||
transactionId,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
type: 'debit',
|
||||
amount: validatedData.amount,
|
||||
service: validatedData.service,
|
||||
serviceId: validatedData.serviceId,
|
||||
description: validatedData.description || `Payment for ${validatedData.service}`,
|
||||
status: 'completed',
|
||||
previousBalance: currentBalance,
|
||||
newBalance,
|
||||
metadata: {
|
||||
userAgent: request.headers.get('user-agent'),
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
|
||||
},
|
||||
})
|
||||
|
||||
await newTransaction.save()
|
||||
console.log('Transaction record saved:', transactionId)
|
||||
} catch (transactionError) {
|
||||
console.error('Failed to save transaction record:', transactionError)
|
||||
// Continue even if transaction record fails - balance was already deducted
|
||||
}
|
||||
|
||||
const responseData = {
|
||||
success: true,
|
||||
data: {
|
||||
transactionId,
|
||||
previousBalance: currentBalance,
|
||||
newBalance,
|
||||
amountDeducted: validatedData.amount,
|
||||
service: validatedData.service,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
// Validate response format
|
||||
const validatedResponse = DeductBalanceResponseSchema.parse(responseData)
|
||||
return NextResponse.json(validatedResponse, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error('Balance deduction error:', error)
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Invalid request data',
|
||||
code: 'VALIDATION_ERROR',
|
||||
details: error.issues,
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to deduct balance', code: 'INTERNAL_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
147
app/api/balance/failure/route.ts
Normal file
147
app/api/balance/failure/route.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import crypto from 'crypto'
|
||||
|
||||
interface PayUBalanceFailureResponse {
|
||||
status: string
|
||||
txnid: string
|
||||
amount: string
|
||||
productinfo: string
|
||||
firstname: string
|
||||
email: string
|
||||
hash: string
|
||||
key: string
|
||||
error?: string
|
||||
error_Message?: string
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance Addition Failure Handler
|
||||
* Processes failed "Add Balance" payments from PayU gateway
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const payuResponse: Partial<PayUBalanceFailureResponse> = {}
|
||||
|
||||
// Extract all PayU response parameters
|
||||
for (const [key, value] of formData.entries()) {
|
||||
payuResponse[key] = value.toString()
|
||||
}
|
||||
|
||||
const { status, txnid, amount, productinfo, firstname, email, hash, key: merchantKey, error, error_Message } = payuResponse
|
||||
|
||||
// Log failure details for debugging
|
||||
console.log(`Balance addition failed - Transaction: ${txnid}, Status: ${status}, Error: ${error || error_Message || 'Unknown'}`)
|
||||
|
||||
// Validate required parameters
|
||||
if (!txnid) {
|
||||
console.error('Missing transaction ID in balance failure response')
|
||||
return NextResponse.redirect(new URL('/payment/failed?error=invalid-response&type=balance', request.url))
|
||||
}
|
||||
|
||||
// Verify PayU hash if provided (for security)
|
||||
if (hash && merchantKey && status && amount) {
|
||||
const merchantSalt = process.env.PAYU_MERCHANT_SALT || 'test-salt'
|
||||
const expectedHashString = `${merchantSalt}|${status}|||||||||||${email}|${firstname}|${productinfo}|${amount}|${txnid}|${merchantKey}`
|
||||
const expectedHash = crypto.createHash('sha512').update(expectedHashString).digest('hex').toLowerCase()
|
||||
|
||||
if (hash.toLowerCase() !== expectedHash) {
|
||||
console.error(`Hash mismatch in balance failure response for transaction: ${txnid}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: Update database with failed balance transaction
|
||||
// In real implementation:
|
||||
// await updateAddBalanceStatus(txnid, 'failed', error || error_Message)
|
||||
|
||||
// Mock database update for demo
|
||||
await mockUpdateBalanceFailure(txnid as string, error || error_Message || 'Payment failed')
|
||||
|
||||
// Determine failure reason for user-friendly message
|
||||
let failureReason = 'unknown'
|
||||
let userMessage = 'Your balance addition failed. Please try again.'
|
||||
|
||||
if (error_Message?.toLowerCase().includes('insufficient')) {
|
||||
failureReason = 'insufficient-funds'
|
||||
userMessage = 'Insufficient funds in your payment method.'
|
||||
} else if (error_Message?.toLowerCase().includes('declined')) {
|
||||
failureReason = 'card-declined'
|
||||
userMessage = 'Your card was declined. Please try a different payment method.'
|
||||
} else if (error_Message?.toLowerCase().includes('timeout')) {
|
||||
failureReason = 'timeout'
|
||||
userMessage = 'Payment timed out. Please try again.'
|
||||
} else if (error_Message?.toLowerCase().includes('cancelled')) {
|
||||
failureReason = 'user-cancelled'
|
||||
userMessage = 'Payment was cancelled.'
|
||||
} else if (error_Message?.toLowerCase().includes('invalid')) {
|
||||
failureReason = 'invalid-details'
|
||||
userMessage = 'Invalid payment details. Please check and try again.'
|
||||
}
|
||||
|
||||
// Redirect to profile page with failure message
|
||||
const failureUrl = new URL('/profile', request.url)
|
||||
failureUrl.searchParams.set('payment', 'failed')
|
||||
failureUrl.searchParams.set('type', 'balance')
|
||||
failureUrl.searchParams.set('reason', failureReason)
|
||||
failureUrl.searchParams.set('txn', txnid as string)
|
||||
failureUrl.searchParams.set('message', encodeURIComponent(userMessage))
|
||||
if (amount) {
|
||||
failureUrl.searchParams.set('amount', amount as string)
|
||||
}
|
||||
|
||||
return NextResponse.redirect(failureUrl)
|
||||
|
||||
} catch (dbError) {
|
||||
console.error('Database update error during balance failure handling:', dbError)
|
||||
// Continue to failure page even if DB update fails
|
||||
const failureUrl = new URL('/profile', request.url)
|
||||
failureUrl.searchParams.set('payment', 'failed')
|
||||
failureUrl.searchParams.set('type', 'balance')
|
||||
failureUrl.searchParams.set('error', 'db-error')
|
||||
failureUrl.searchParams.set('txn', txnid as string)
|
||||
|
||||
return NextResponse.redirect(failureUrl)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Balance failure handler error:', error)
|
||||
return NextResponse.redirect(new URL('/profile?payment=failed&type=balance&error=processing-error', request.url))
|
||||
}
|
||||
}
|
||||
|
||||
// Mock function for demonstration
|
||||
async function mockUpdateBalanceFailure(txnid: string, errorMessage: string) {
|
||||
console.log(`Mock DB Update: Balance Transaction ${txnid} failed`)
|
||||
console.log(`Failure reason: ${errorMessage}`)
|
||||
|
||||
// Mock: Update add_balance_history status
|
||||
const historyUpdate = {
|
||||
transaction_id: txnid,
|
||||
status: 'failed',
|
||||
error_message: errorMessage,
|
||||
updated_at: new Date(),
|
||||
retry_count: 1, // Could track retry attempts
|
||||
payment_gateway_response: {
|
||||
status: 'failed',
|
||||
error: errorMessage,
|
||||
failed_at: new Date()
|
||||
}
|
||||
}
|
||||
|
||||
// Mock: Could also track failed attempt statistics
|
||||
const analytics = {
|
||||
failed_balance_additions: 1,
|
||||
common_failure_reasons: {
|
||||
[errorMessage]: 1
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Mock: Balance failure recorded for analysis')
|
||||
|
||||
// Mock delay
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
return { historyUpdate, analytics }
|
||||
}
|
||||
132
app/api/balance/success/route.ts
Normal file
132
app/api/balance/success/route.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import crypto from 'crypto'
|
||||
|
||||
interface PayUBalanceResponse {
|
||||
status: string
|
||||
txnid: string
|
||||
amount: string
|
||||
productinfo: string
|
||||
firstname: string
|
||||
email: string
|
||||
hash: string
|
||||
key: string
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance Addition Success Handler
|
||||
* Processes successful "Add Balance" payments from PayU gateway
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const payuResponse: Partial<PayUBalanceResponse> = {}
|
||||
|
||||
// Extract all PayU response parameters
|
||||
for (const [key, value] of formData.entries()) {
|
||||
payuResponse[key] = value.toString()
|
||||
}
|
||||
|
||||
const { status, txnid, amount, productinfo, firstname, email, hash, key: merchantKey } = payuResponse
|
||||
|
||||
// Validate required parameters
|
||||
if (!status || !txnid || !amount || !hash) {
|
||||
console.error('Missing required PayU parameters in balance success')
|
||||
return NextResponse.redirect(new URL('/payment/failed?error=invalid-response', request.url))
|
||||
}
|
||||
|
||||
// Verify payment status
|
||||
if (status !== 'success') {
|
||||
console.log(`Balance payment failed for transaction: ${txnid}, status: ${status}`)
|
||||
return NextResponse.redirect(new URL('/payment/failed?txn=' + txnid + '&type=balance', request.url))
|
||||
}
|
||||
|
||||
// Verify PayU hash for security
|
||||
const merchantSalt = process.env.PAYU_MERCHANT_SALT || 'test-salt'
|
||||
const expectedHashString = `${merchantSalt}|${status}|||||||||||${email}|${firstname}|${productinfo}|${amount}|${txnid}|${merchantKey}`
|
||||
const expectedHash = crypto.createHash('sha512').update(expectedHashString).digest('hex').toLowerCase()
|
||||
|
||||
if (hash?.toLowerCase() !== expectedHash) {
|
||||
console.error(`Hash mismatch for balance transaction: ${txnid}`)
|
||||
return NextResponse.redirect(new URL('/payment/failed?error=hash-mismatch&type=balance', request.url))
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: Update database with successful balance addition
|
||||
// In real implementation:
|
||||
// const siliconId = await getSiliconIdFromTransaction(txnid)
|
||||
// await updateAddBalanceStatus(txnid, 'success')
|
||||
// await incrementUserBalance(siliconId, parseFloat(amount))
|
||||
|
||||
console.log(`Balance addition successful for transaction: ${txnid}, amount: ₹${amount}`)
|
||||
|
||||
// Mock database update for demo
|
||||
const updateResult = await mockUpdateBalanceSuccess(txnid as string, parseFloat(amount as string))
|
||||
|
||||
// Redirect to profile page with success message
|
||||
const successUrl = new URL('/profile', request.url)
|
||||
successUrl.searchParams.set('payment', 'success')
|
||||
successUrl.searchParams.set('type', 'balance')
|
||||
successUrl.searchParams.set('amount', amount as string)
|
||||
successUrl.searchParams.set('txn', txnid as string)
|
||||
|
||||
return NextResponse.redirect(successUrl)
|
||||
|
||||
} catch (dbError) {
|
||||
console.error('Database update error during balance success:', dbError)
|
||||
// Log for manual reconciliation - payment was successful at gateway
|
||||
console.error(`CRITICAL: Balance payment ${txnid} succeeded at PayU but DB update failed`)
|
||||
|
||||
// Still redirect to success but with warning
|
||||
const successUrl = new URL('/profile', request.url)
|
||||
successUrl.searchParams.set('payment', 'success')
|
||||
successUrl.searchParams.set('type', 'balance')
|
||||
successUrl.searchParams.set('amount', amount as string)
|
||||
successUrl.searchParams.set('txn', txnid as string)
|
||||
successUrl.searchParams.set('warning', 'db-update-failed')
|
||||
|
||||
return NextResponse.redirect(successUrl)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Balance success handler error:', error)
|
||||
return NextResponse.redirect(new URL('/payment/failed?error=processing-error&type=balance', request.url))
|
||||
}
|
||||
}
|
||||
|
||||
// Mock function for demonstration
|
||||
async function mockUpdateBalanceSuccess(txnid: string, amount: number) {
|
||||
console.log(`Mock DB Update: Balance Transaction ${txnid} successful`)
|
||||
|
||||
// Mock: Get user Silicon ID from transaction
|
||||
const mockSiliconId = 'USR_12345' // In real implementation, fetch from add_balance_history table
|
||||
|
||||
// Mock: Update add_balance_history status
|
||||
const historyUpdate = {
|
||||
transaction_id: txnid,
|
||||
status: 'success',
|
||||
updated_at: new Date(),
|
||||
payment_gateway_response: {
|
||||
amount: amount,
|
||||
currency: 'INR',
|
||||
payment_date: new Date()
|
||||
}
|
||||
}
|
||||
|
||||
// Mock: Update user balance
|
||||
const balanceUpdate = {
|
||||
silicon_id: mockSiliconId,
|
||||
balance_increment: amount,
|
||||
previous_balance: 5000, // Mock previous balance
|
||||
new_balance: 5000 + amount,
|
||||
updated_at: new Date()
|
||||
}
|
||||
|
||||
console.log(`Mock: User ${mockSiliconId} balance increased by ₹${amount}`)
|
||||
console.log(`Mock: New balance: ₹${balanceUpdate.new_balance}`)
|
||||
|
||||
// Mock delay
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
|
||||
return { historyUpdate, balanceUpdate }
|
||||
}
|
||||
102
app/api/billing/route.ts
Normal file
102
app/api/billing/route.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import { User as UserModel } from '@/models/user'
|
||||
import BillingService from '@/lib/billing-service'
|
||||
|
||||
// Schema for billing query parameters
|
||||
const BillingQuerySchema = z.object({
|
||||
serviceType: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
limit: z.coerce.number().min(1).max(100).default(20),
|
||||
offset: z.coerce.number().min(0).default(0),
|
||||
})
|
||||
|
||||
// GET endpoint to fetch user's billing records
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Authentication required', code: 'UNAUTHORIZED' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
await connectDB()
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const queryParams = {
|
||||
serviceType: searchParams.get('serviceType') || undefined,
|
||||
status: searchParams.get('status') || undefined,
|
||||
limit: searchParams.get('limit') || '20',
|
||||
offset: searchParams.get('offset') || '0',
|
||||
}
|
||||
|
||||
const validatedParams = BillingQuerySchema.parse(queryParams)
|
||||
|
||||
// Get user data
|
||||
const userData = await UserModel.findOne({ email: user.email })
|
||||
if (!userData) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'User not found', code: 'USER_NOT_FOUND' },
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch billing records
|
||||
const billings = await BillingService.getUserBillings(user.email, user.id, {
|
||||
serviceType: validatedParams.serviceType,
|
||||
status: validatedParams.status,
|
||||
limit: validatedParams.limit,
|
||||
offset: validatedParams.offset,
|
||||
})
|
||||
|
||||
// Get billing statistics
|
||||
const stats = await BillingService.getBillingStats(user.email, user.id)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
billings,
|
||||
stats,
|
||||
pagination: {
|
||||
limit: validatedParams.limit,
|
||||
offset: validatedParams.offset,
|
||||
total: billings.length,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch billing records:', error)
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Invalid query parameters',
|
||||
code: 'VALIDATION_ERROR',
|
||||
details: error.issues,
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to fetch billing records', code: 'INTERNAL_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
57
app/api/billing/stats/route.ts
Normal file
57
app/api/billing/stats/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import { User as UserModel } from '@/models/user'
|
||||
import BillingService from '@/lib/billing-service'
|
||||
|
||||
// GET endpoint to fetch user's billing statistics
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Authentication required', code: 'UNAUTHORIZED' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
await connectDB()
|
||||
|
||||
// Get user data
|
||||
const userData = await UserModel.findOne({ email: user.email })
|
||||
if (!userData) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'User not found', code: 'USER_NOT_FOUND' },
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get comprehensive billing statistics
|
||||
const stats = await BillingService.getBillingStats(user.email, user.id)
|
||||
const activeServices = await BillingService.getActiveServices(user.email, user.id)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
...stats,
|
||||
activeServicesList: activeServices,
|
||||
currentBalance: userData.balance || 0,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch billing statistics:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to fetch billing statistics', code: 'INTERNAL_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
80
app/api/contact/route.ts
Normal file
80
app/api/contact/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Validation schema matching sp_25 structure
|
||||
const contactSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').trim(),
|
||||
email: z.string().email('Invalid email format').trim(),
|
||||
company: z.string().optional(),
|
||||
service_intrest: z.string().min(1, 'Service interest is required'),
|
||||
message: z.string().min(1, 'Message is required').trim(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate the request body
|
||||
const validatedData = contactSchema.parse(body)
|
||||
|
||||
// Get client IP address
|
||||
const ip_address = request.headers.get('x-forwarded-for') ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown'
|
||||
|
||||
// TODO: Database integration
|
||||
// This would integrate with MongoDB/database in production
|
||||
// For now, just simulate successful submission
|
||||
|
||||
// TODO: Email notification
|
||||
// This would send email notification in production
|
||||
// const emailData = {
|
||||
// to: 'contact@siliconpin.com',
|
||||
// subject: 'New Contact Form Submission',
|
||||
// body: `
|
||||
// Name: ${validatedData.name}
|
||||
// Email: ${validatedData.email}
|
||||
// Company: ${validatedData.company || 'Not provided'}
|
||||
// Service Interest: ${validatedData.service_intrest}
|
||||
// Message: ${validatedData.message}
|
||||
// IP Address: ${ip_address}
|
||||
// `
|
||||
// }
|
||||
|
||||
// Log the submission (for development)
|
||||
console.log('Contact form submission:', {
|
||||
...validatedData,
|
||||
ip_address,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
// Simulate processing delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Thank you for your message! We\'ll get back to you soon.'
|
||||
}, { status: 200 })
|
||||
|
||||
} catch (error) {
|
||||
console.error('Contact form error:', error)
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: 'Validation failed',
|
||||
errors: error.issues.reduce((acc, err) => {
|
||||
if (err.path.length > 0) {
|
||||
acc[err.path[0] as string] = err.message
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: 'Internal server error. Please try again later.'
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
138
app/api/dashboard/route.ts
Normal file
138
app/api/dashboard/route.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import TopicModel from '@/models/topic'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
|
||||
// Response schema for type safety
|
||||
const DashboardStatsSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.object({
|
||||
stats: z.object({
|
||||
totalTopics: z.number(),
|
||||
publishedTopics: z.number(),
|
||||
draftTopics: z.number(),
|
||||
totalViews: z.number(),
|
||||
}),
|
||||
userTopics: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
slug: z.string(),
|
||||
publishedAt: z.number(),
|
||||
isDraft: z.boolean(),
|
||||
views: z.number().optional(),
|
||||
excerpt: z.string(),
|
||||
tags: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
export type DashboardResponse = z.infer<typeof DashboardStatsSchema>
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Authentication required', code: 'UNAUTHORIZED' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Connect to database
|
||||
await connectDB()
|
||||
|
||||
// MongoDB Aggregation Pipeline for user topic statistics
|
||||
const userTopics = await TopicModel.aggregate([
|
||||
{
|
||||
// Match topics by user ownership
|
||||
$match: {
|
||||
authorId: user.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Sort by publication date (newest first)
|
||||
$sort: {
|
||||
publishedAt: -1,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Project only the fields we need for the dashboard
|
||||
$project: {
|
||||
id: '$id',
|
||||
title: '$title',
|
||||
slug: '$slug',
|
||||
publishedAt: '$publishedAt',
|
||||
isDraft: '$isDraft',
|
||||
views: { $ifNull: ['$views', 0] }, // Default to 0 if views field doesn't exist
|
||||
excerpt: '$excerpt',
|
||||
tags: {
|
||||
$map: {
|
||||
input: '$tags',
|
||||
as: 'tag',
|
||||
in: {
|
||||
name: '$$tag.name',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// Calculate statistics from the aggregated data
|
||||
const totalTopics = userTopics.length
|
||||
const publishedTopics = userTopics.filter((topic) => !topic.isDraft).length
|
||||
const draftTopics = userTopics.filter((topic) => topic.isDraft).length
|
||||
const totalViews = userTopics.reduce((sum, topic) => sum + (topic.views || 0), 0)
|
||||
|
||||
// Prepare response data
|
||||
const responseData = {
|
||||
success: true,
|
||||
data: {
|
||||
stats: {
|
||||
totalTopics,
|
||||
publishedTopics,
|
||||
draftTopics,
|
||||
totalViews,
|
||||
},
|
||||
userTopics,
|
||||
},
|
||||
}
|
||||
|
||||
// Validate response format matches frontend expectations
|
||||
const validatedResponse = DashboardStatsSchema.parse(responseData)
|
||||
|
||||
return NextResponse.json(validatedResponse, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error('Dashboard API error:', error)
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Invalid response format', code: 'VALIDATION_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to fetch dashboard data', code: 'INTERNAL_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
39
app/api/debug/topics/route.ts
Normal file
39
app/api/debug/topics/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import TopicModel from '@/models/topic'
|
||||
|
||||
// Debug endpoint to see what topics exist in the database
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
await connectDB()
|
||||
|
||||
// Get all topics with basic info
|
||||
const topics = await TopicModel.find({}, {
|
||||
id: 1,
|
||||
slug: 1,
|
||||
title: 1,
|
||||
isDraft: 1,
|
||||
views: 1,
|
||||
authorId: 1
|
||||
}).sort({ publishedAt: -1 })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
count: topics.length,
|
||||
topics: topics.map(topic => ({
|
||||
id: topic.id,
|
||||
slug: topic.slug,
|
||||
title: topic.title,
|
||||
isDraft: topic.isDraft,
|
||||
views: topic.views || 0,
|
||||
authorId: topic.authorId
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Debug topics error:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
95
app/api/feedback/route.ts
Normal file
95
app/api/feedback/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Validation schema matching sp_25 structure
|
||||
const feedbackSchema = z.object({
|
||||
type: z.enum(['suggestion', 'report']),
|
||||
name: z.string().min(1, 'Please enter your name.').trim(),
|
||||
email: z.string().email('Please enter a valid email address.').trim(),
|
||||
title: z.string().min(1, 'Title is required').trim(),
|
||||
details: z.string().min(1, 'Details are required').trim(),
|
||||
category: z.string().default('general'),
|
||||
urgency: z.string().default('low'),
|
||||
siliconId: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate the request body
|
||||
const validatedData = feedbackSchema.parse(body)
|
||||
|
||||
// Get client IP address and referrer
|
||||
const ip_address = request.headers.get('x-forwarded-for') ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown'
|
||||
const referrer = request.headers.get('referer') || 'Direct Access'
|
||||
|
||||
// TODO: Database integration
|
||||
// This would integrate with MongoDB/database in production
|
||||
// INSERT INTO sp_feedback (type, name, email, title, details, urgency, category, referrer, siliconId, ip_address, created_at)
|
||||
|
||||
// TODO: Email notification
|
||||
const emailData = {
|
||||
to: 'contact@siliconpin.com',
|
||||
subject: `New ${validatedData.type.charAt(0).toUpperCase() + validatedData.type.slice(1)} Submission`,
|
||||
body: `
|
||||
Type: ${validatedData.type.charAt(0).toUpperCase() + validatedData.type.slice(1)}
|
||||
Name: ${validatedData.name}
|
||||
Email: ${validatedData.email}
|
||||
Title: ${validatedData.title}
|
||||
Category: ${validatedData.category}
|
||||
${validatedData.type === 'report' ? `Urgency: ${validatedData.urgency}\n` : ''}
|
||||
Details:
|
||||
${validatedData.details}
|
||||
|
||||
Referrer: ${referrer}
|
||||
IP Address: ${ip_address}
|
||||
${validatedData.siliconId ? `SiliconID: ${validatedData.siliconId}\n` : ''}
|
||||
`.trim()
|
||||
}
|
||||
|
||||
// Log the submission (for development)
|
||||
console.log('Feedback submission:', {
|
||||
...validatedData,
|
||||
referrer,
|
||||
ip_address,
|
||||
timestamp: new Date().toISOString(),
|
||||
emailData
|
||||
})
|
||||
|
||||
// Simulate processing delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
const successMessage = validatedData.type === 'suggestion'
|
||||
? 'Thank you for your suggestion! We appreciate your feedback.'
|
||||
: 'Your report has been submitted. We will look into it as soon as possible.'
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: successMessage
|
||||
}, { status: 200 })
|
||||
|
||||
} catch (error) {
|
||||
console.error('Feedback submission error:', error)
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: 'Validation failed',
|
||||
errors: error.issues.reduce((acc, err) => {
|
||||
if (err.path.length > 0) {
|
||||
acc[err.path[0] as string] = err.message
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: 'Internal server error. Please try again later.'
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
63
app/api/health/route.ts
Normal file
63
app/api/health/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { connectDB } from '@/lib/mongodb'
|
||||
import { createClient } from 'redis'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const startTime = Date.now()
|
||||
|
||||
const healthCheck = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV,
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
checks: {
|
||||
database: { status: 'unknown' as 'healthy' | 'unhealthy' | 'unknown' },
|
||||
redis: { status: 'unknown' as 'healthy' | 'unhealthy' | 'unknown' },
|
||||
},
|
||||
responseTime: 0,
|
||||
}
|
||||
|
||||
// Check database connection
|
||||
try {
|
||||
await connectDB()
|
||||
healthCheck.checks.database.status = 'healthy'
|
||||
} catch (error) {
|
||||
healthCheck.checks.database.status = 'unhealthy'
|
||||
healthCheck.status = 'unhealthy'
|
||||
}
|
||||
|
||||
// Check Redis connection
|
||||
try {
|
||||
const redis = createClient({
|
||||
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
})
|
||||
|
||||
await redis.connect()
|
||||
await redis.ping()
|
||||
await redis.disconnect()
|
||||
|
||||
healthCheck.checks.redis.status = 'healthy'
|
||||
} catch (error) {
|
||||
healthCheck.checks.redis.status = 'unhealthy'
|
||||
healthCheck.status = 'unhealthy'
|
||||
}
|
||||
|
||||
// Calculate response time
|
||||
healthCheck.responseTime = Date.now() - startTime
|
||||
|
||||
// Return appropriate status code
|
||||
const statusCode = healthCheck.status === 'healthy' ? 200 : 503
|
||||
|
||||
return NextResponse.json(healthCheck, { status: statusCode })
|
||||
}
|
||||
|
||||
// Readiness check - simpler check for container orchestration
|
||||
export async function HEAD(request: NextRequest) {
|
||||
try {
|
||||
// Quick check if the application is ready to serve requests
|
||||
return new NextResponse(null, { status: 200 })
|
||||
} catch (error) {
|
||||
return new NextResponse(null, { status: 503 })
|
||||
}
|
||||
}
|
||||
115
app/api/payments/failure/route.ts
Normal file
115
app/api/payments/failure/route.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import crypto from 'crypto'
|
||||
|
||||
interface PayUFailureResponse {
|
||||
status: string
|
||||
txnid: string
|
||||
amount: string
|
||||
productinfo: string
|
||||
firstname: string
|
||||
email: string
|
||||
hash: string
|
||||
key: string
|
||||
error?: string
|
||||
error_Message?: string
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* PayU Payment Failure Handler
|
||||
* Processes failed payment responses from PayU gateway
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const payuResponse: Partial<PayUFailureResponse> = {}
|
||||
|
||||
// Extract all PayU response parameters
|
||||
for (const [key, value] of formData.entries()) {
|
||||
payuResponse[key] = value.toString()
|
||||
}
|
||||
|
||||
const { status, txnid, amount, productinfo, firstname, email, hash, key: merchantKey, error, error_Message } = payuResponse
|
||||
|
||||
// Log failure details for debugging
|
||||
console.log(`Payment failed - Transaction: ${txnid}, Status: ${status}, Error: ${error || error_Message || 'Unknown'}`)
|
||||
|
||||
// Validate required parameters
|
||||
if (!txnid) {
|
||||
console.error('Missing transaction ID in failure response')
|
||||
return NextResponse.redirect(new URL('/payment/failed?error=invalid-response', request.url))
|
||||
}
|
||||
|
||||
// Verify PayU hash if provided (for security)
|
||||
if (hash && merchantKey && status && amount) {
|
||||
const merchantSalt = process.env.PAYU_MERCHANT_SALT || 'test-salt'
|
||||
const expectedHashString = `${merchantSalt}|${status}|||||||||||${email}|${firstname}|${productinfo}|${amount}|${txnid}|${merchantKey}`
|
||||
const expectedHash = crypto.createHash('sha512').update(expectedHashString).digest('hex').toLowerCase()
|
||||
|
||||
if (hash.toLowerCase() !== expectedHash) {
|
||||
console.error(`Hash mismatch in failure response for transaction: ${txnid}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: Update database with failed payment status
|
||||
// In a real implementation:
|
||||
// await updateBillingStatus(txnid, 'failed', error || error_Message)
|
||||
|
||||
// Mock database update for demo
|
||||
await mockUpdatePaymentStatus(txnid as string, 'failed', error || error_Message || 'Payment failed')
|
||||
|
||||
// Determine failure reason for user-friendly message
|
||||
let failureReason = 'unknown'
|
||||
if (error_Message?.toLowerCase().includes('insufficient')) {
|
||||
failureReason = 'insufficient-funds'
|
||||
} else if (error_Message?.toLowerCase().includes('declined')) {
|
||||
failureReason = 'card-declined'
|
||||
} else if (error_Message?.toLowerCase().includes('timeout')) {
|
||||
failureReason = 'timeout'
|
||||
} else if (error_Message?.toLowerCase().includes('cancelled')) {
|
||||
failureReason = 'user-cancelled'
|
||||
}
|
||||
|
||||
// Redirect to failure page with transaction details
|
||||
const failureUrl = new URL('/payment/failed', request.url)
|
||||
failureUrl.searchParams.set('txn', txnid as string)
|
||||
failureUrl.searchParams.set('reason', failureReason)
|
||||
if (amount) {
|
||||
failureUrl.searchParams.set('amount', amount as string)
|
||||
}
|
||||
|
||||
return NextResponse.redirect(failureUrl)
|
||||
|
||||
} catch (dbError) {
|
||||
console.error('Database update error during failure handling:', dbError)
|
||||
// Continue to failure page even if DB update fails
|
||||
return NextResponse.redirect(new URL('/payment/failed?error=db-error&txn=' + txnid, request.url))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Payment failure handler error:', error)
|
||||
return NextResponse.redirect(new URL('/payment/failed?error=processing-error', request.url))
|
||||
}
|
||||
}
|
||||
|
||||
// Mock function for demonstration
|
||||
async function mockUpdatePaymentStatus(txnid: string, status: string, errorMessage: string) {
|
||||
// In real implementation, this would update MongoDB/database
|
||||
console.log(`Mock DB Update: Transaction ${txnid} marked as ${status}`)
|
||||
console.log(`Failure reason: ${errorMessage}`)
|
||||
|
||||
// Simulate database operations
|
||||
const billingUpdate = {
|
||||
billing_id: txnid,
|
||||
payment_status: status,
|
||||
payment_date: new Date(),
|
||||
error_message: errorMessage,
|
||||
retry_count: 1 // Could track retry attempts
|
||||
}
|
||||
|
||||
// Mock delay
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
return billingUpdate
|
||||
}
|
||||
112
app/api/payments/initiate/route.ts
Normal file
112
app/api/payments/initiate/route.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import crypto from 'crypto'
|
||||
|
||||
interface PaymentInitiateRequest {
|
||||
billing_id: string
|
||||
amount: number
|
||||
service: string
|
||||
}
|
||||
|
||||
interface UserTokenPayload {
|
||||
siliconId: string
|
||||
email: string
|
||||
type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PayU Payment Gateway Integration
|
||||
* Initiates payment for billing records
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Verify user authentication
|
||||
const token = request.cookies.get('accessToken')?.value
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const secret = process.env.JWT_SECRET || 'your-secret-key'
|
||||
const user = jwt.verify(token, secret) as UserTokenPayload
|
||||
|
||||
// Parse request body
|
||||
const body: PaymentInitiateRequest = await request.json()
|
||||
const { billing_id, amount, service } = body
|
||||
|
||||
// Validate input
|
||||
if (!billing_id || !amount || amount <= 0 || !service) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Invalid payment parameters' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Verify billing record exists and belongs to user
|
||||
// In a real implementation, you would check database:
|
||||
// const billing = await verifyBillingRecord(billing_id, user.siliconId)
|
||||
|
||||
// PayU configuration (from environment variables)
|
||||
const merchantKey = process.env.PAYU_MERCHANT_KEY || 'test-key'
|
||||
const merchantSalt = process.env.PAYU_MERCHANT_SALT || 'test-salt'
|
||||
const payuUrl = process.env.PAYU_URL || 'https://test.payu.in/_payment'
|
||||
|
||||
// Prepare payment data
|
||||
const txnid = billing_id
|
||||
const productinfo = service.substring(0, 100)
|
||||
const firstname = 'Customer'
|
||||
const email = user.email
|
||||
const phone = '9876543210' // Default phone or fetch from user profile
|
||||
|
||||
// Success and failure URLs
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:4023'
|
||||
const surl = `${baseUrl}/api/payments/success`
|
||||
const furl = `${baseUrl}/api/payments/failure`
|
||||
|
||||
// Generate PayU hash
|
||||
const hashString = `${merchantKey}|${txnid}|${amount}|${productinfo}|${firstname}|${email}|||||||||||${merchantSalt}`
|
||||
const hash = crypto.createHash('sha512').update(hashString).digest('hex')
|
||||
|
||||
// Return payment form data for frontend submission
|
||||
const paymentData = {
|
||||
success: true,
|
||||
payment_url: payuUrl,
|
||||
form_data: {
|
||||
key: merchantKey,
|
||||
txnid,
|
||||
amount: amount.toFixed(2),
|
||||
productinfo,
|
||||
firstname,
|
||||
email,
|
||||
phone,
|
||||
surl,
|
||||
furl,
|
||||
hash,
|
||||
service_provider: 'payu_paisa',
|
||||
},
|
||||
}
|
||||
|
||||
return NextResponse.json(paymentData)
|
||||
} catch (error) {
|
||||
console.error('Payment initiation error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Payment initiation failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Mock function - in real implementation, verify against database
|
||||
async function verifyBillingRecord(billingId: string, siliconId: string) {
|
||||
// TODO: Implement database verification
|
||||
// Check if billing record exists and belongs to the user
|
||||
return {
|
||||
billing_id: billingId,
|
||||
amount: 1000,
|
||||
service: 'Cloud Instance',
|
||||
user_silicon_id: siliconId,
|
||||
status: 'pending',
|
||||
}
|
||||
}
|
||||
110
app/api/payments/success/route.ts
Normal file
110
app/api/payments/success/route.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import crypto from 'crypto'
|
||||
|
||||
interface PayUResponse {
|
||||
status: string
|
||||
txnid: string
|
||||
amount: string
|
||||
productinfo: string
|
||||
firstname: string
|
||||
email: string
|
||||
hash: string
|
||||
key: string
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PayU Payment Success Handler
|
||||
* Processes successful payment responses from PayU gateway
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const payuResponse: Partial<PayUResponse> = {}
|
||||
|
||||
// Extract all PayU response parameters
|
||||
for (const [key, value] of formData.entries()) {
|
||||
payuResponse[key] = value.toString()
|
||||
}
|
||||
|
||||
const { status, txnid, amount, productinfo, firstname, email, hash, key: merchantKey } = payuResponse
|
||||
|
||||
// Validate required parameters
|
||||
if (!status || !txnid || !amount || !hash) {
|
||||
console.error('Missing required PayU parameters')
|
||||
return NextResponse.redirect(new URL('/payment/failed?error=invalid-response', request.url))
|
||||
}
|
||||
|
||||
// Verify payment status
|
||||
if (status !== 'success') {
|
||||
console.log(`Payment failed for transaction: ${txnid}, status: ${status}`)
|
||||
return NextResponse.redirect(new URL('/payment/failed?txn=' + txnid, request.url))
|
||||
}
|
||||
|
||||
// Verify PayU hash for security
|
||||
const merchantSalt = process.env.PAYU_MERCHANT_SALT || 'test-salt'
|
||||
const expectedHashString = `${merchantSalt}|${status}|||||||||||${email}|${firstname}|${productinfo}|${amount}|${txnid}|${merchantKey}`
|
||||
const expectedHash = crypto.createHash('sha512').update(expectedHashString).digest('hex').toLowerCase()
|
||||
|
||||
if (hash?.toLowerCase() !== expectedHash) {
|
||||
console.error(`Hash mismatch for transaction: ${txnid}`)
|
||||
// Log potential fraud attempt
|
||||
console.error('Expected hash:', expectedHash)
|
||||
console.error('Received hash:', hash?.toLowerCase())
|
||||
return NextResponse.redirect(new URL('/payment/failed?error=hash-mismatch', request.url))
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: Update database with successful payment
|
||||
// In a real implementation:
|
||||
// await updateBillingStatus(txnid, 'success')
|
||||
// await updateUserBalance(userSiliconId, parseFloat(amount))
|
||||
|
||||
console.log(`Payment successful for transaction: ${txnid}, amount: ₹${amount}`)
|
||||
|
||||
// Mock database update for demo
|
||||
await mockUpdatePaymentStatus(txnid as string, 'success', parseFloat(amount as string))
|
||||
|
||||
// Redirect to success page with transaction details
|
||||
const successUrl = new URL('/payment/success', request.url)
|
||||
successUrl.searchParams.set('txn', txnid as string)
|
||||
successUrl.searchParams.set('amount', amount as string)
|
||||
|
||||
return NextResponse.redirect(successUrl)
|
||||
|
||||
} catch (dbError) {
|
||||
console.error('Database update error:', dbError)
|
||||
// Even if DB update fails, payment was successful at gateway
|
||||
// Log for manual reconciliation
|
||||
return NextResponse.redirect(new URL('/payment/success?warning=db-update-failed&txn=' + txnid, request.url))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Payment success handler error:', error)
|
||||
return NextResponse.redirect(new URL('/payment/failed?error=processing-error', request.url))
|
||||
}
|
||||
}
|
||||
|
||||
// Mock function for demonstration
|
||||
async function mockUpdatePaymentStatus(txnid: string, status: string, amount: number) {
|
||||
// In real implementation, this would update MongoDB/database
|
||||
console.log(`Mock DB Update: Transaction ${txnid} marked as ${status}, amount: ₹${amount}`)
|
||||
|
||||
// Simulate database operations
|
||||
const billingUpdate = {
|
||||
billing_id: txnid,
|
||||
payment_status: status,
|
||||
payment_date: new Date(),
|
||||
amount: amount
|
||||
}
|
||||
|
||||
const userBalanceUpdate = {
|
||||
// This would update user's account balance for "add_balance" transactions
|
||||
increment: status === 'success' && txnid.includes('balance') ? amount : 0
|
||||
}
|
||||
|
||||
// Mock delay
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
return { billingUpdate, userBalanceUpdate }
|
||||
}
|
||||
198
app/api/services/deploy-cloude/route.ts
Normal file
198
app/api/services/deploy-cloude/route.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import { User as UserModel } from '@/models/user'
|
||||
import BillingService from '@/lib/billing-service'
|
||||
import { checkServiceAvailability } from '@/lib/system-settings'
|
||||
|
||||
// VPC subnet mapping based on datacenter
|
||||
const VPC_SUBNET_MAP: { [key: string]: string } = {
|
||||
inmumbaizone2: '3889f7ca-ca19-4851-abc7-5c6b4798b3fe', // Mumbai public subnet
|
||||
inbangalore: 'c17032c9-3cfd-4028-8f2a-f3f5aa8c2976', // Bangalore public subnet
|
||||
innoida: '95c60b59-4925-4b7e-bd05-afbf940d8000', // Delhi/Noida public subnet
|
||||
defra1: '72ea201d-e1e7-4d30-a186-bc709efafad8', // Frankfurt public subnet
|
||||
uslosangeles: '0e253cd1-8ecc-4b65-ae0f-6acbc53b0fb6', // Los Angeles public subnet
|
||||
inbangalore3: 'f687a58b-04f0-4ebe-b583-65788a1d18bf', // Bangalore DC3 public subnet
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check if VPS deployment service is enabled
|
||||
if (!(await checkServiceAvailability('vps'))) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'VPS deployment service is currently disabled by administrator',
|
||||
},
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check authentication using your auth middleware
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', message: 'Unauthorized: Please login to continue' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get input data from request
|
||||
const input = await request.json()
|
||||
if (!input) {
|
||||
return NextResponse.json({ status: 'error', message: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get API key from environment
|
||||
const UTHO_API_KEY = process.env.UTHO_API_KEY
|
||||
if (!UTHO_API_KEY) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', message: 'API configuration missing' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Connect to MongoDB
|
||||
await connectDB()
|
||||
|
||||
// Get user data for billing
|
||||
const userData = await UserModel.findOne({ email: user.email })
|
||||
if (!userData) {
|
||||
return NextResponse.json({ status: 'error', message: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const requiredAmount = input.amount || 0
|
||||
|
||||
// Check balance only if amount > 0 (some services might be free)
|
||||
if (requiredAmount > 0) {
|
||||
const currentBalance = userData.balance || 0
|
||||
if (currentBalance < requiredAmount) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: `Insufficient balance. Required: ₹${requiredAmount}, Available: ₹${currentBalance}`,
|
||||
code: 'INSUFFICIENT_BALANCE',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Get VPC subnet based on datacenter
|
||||
const vpcSubnet = VPC_SUBNET_MAP[input.dclocation] || VPC_SUBNET_MAP['inbangalore']
|
||||
|
||||
// Prepare Utho payload - use the exact format from your PHP code
|
||||
const uthoPayload = {
|
||||
dcslug: input.dclocation,
|
||||
planid: input.planid,
|
||||
billingcycle: 'hourly',
|
||||
auth: 'option2',
|
||||
enable_publicip: input.publicip !== false,
|
||||
subnetRequired: false,
|
||||
firewall: '23434645',
|
||||
cpumodel: 'intel',
|
||||
enablebackup: input.backup || false,
|
||||
root_password: input.password,
|
||||
support: 'unmanaged',
|
||||
vpc: vpcSubnet,
|
||||
cloud: [
|
||||
{
|
||||
hostname: input.hostname,
|
||||
},
|
||||
],
|
||||
image: input.image,
|
||||
sshkeys: '',
|
||||
}
|
||||
|
||||
console.log('Sending to Utho API:', uthoPayload)
|
||||
|
||||
// Make API request to Utho
|
||||
const UTHO_API_URL = 'https://api.utho.com/v2/cloud/deploy'
|
||||
const response = await fetch(UTHO_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${UTHO_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: '*/*',
|
||||
},
|
||||
body: JSON.stringify(uthoPayload),
|
||||
})
|
||||
|
||||
const httpCode = response.status
|
||||
const deploymentSuccess = httpCode >= 200 && httpCode < 300
|
||||
const responseData = await response.json()
|
||||
|
||||
// Add status field like PHP does
|
||||
responseData.status = deploymentSuccess ? 'success' : 'error'
|
||||
|
||||
console.log('Utho API response:', { httpCode, responseData })
|
||||
|
||||
// Process billing using the new comprehensive billing service
|
||||
try {
|
||||
const billingResult = await BillingService.processServiceDeployment({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
siliconId: userData.siliconId,
|
||||
},
|
||||
service: {
|
||||
name: 'VPS Server',
|
||||
type: 'vps',
|
||||
id: responseData.server_id || responseData.id,
|
||||
instanceId: responseData.server_id || responseData.id,
|
||||
config: {
|
||||
hostname: input.hostname,
|
||||
planid: input.planid,
|
||||
dclocation: input.dclocation,
|
||||
image: input.image,
|
||||
backup: input.backup,
|
||||
publicip: input.publicip,
|
||||
},
|
||||
},
|
||||
amount: requiredAmount,
|
||||
currency: 'INR',
|
||||
cycle: (input.cycle as any) || 'onetime',
|
||||
deploymentSuccess,
|
||||
deploymentResponse: responseData,
|
||||
metadata: {
|
||||
userAgent: request.headers.get('user-agent'),
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
|
||||
uthoPayload,
|
||||
httpCode,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Billing processed:', {
|
||||
billingId: billingResult.billing.billing_id,
|
||||
transactionId: billingResult.transaction?.transactionId,
|
||||
balanceUpdated: billingResult.balanceUpdated,
|
||||
})
|
||||
} catch (billingError) {
|
||||
console.error('Billing processing failed:', billingError)
|
||||
// Continue even if billing fails, but return error if it's balance-related
|
||||
if (billingError instanceof Error && billingError.message.includes('Insufficient balance')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: billingError.message,
|
||||
code: 'INSUFFICIENT_BALANCE',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the exact same response format as PHP
|
||||
return NextResponse.json(responseData, { status: httpCode })
|
||||
} catch (error) {
|
||||
console.error('Cloud deployment error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Internal server error',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
247
app/api/services/deploy-kubernetes/route.ts
Normal file
247
app/api/services/deploy-kubernetes/route.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import { User as UserModel } from '@/models/user'
|
||||
import BillingService from '@/lib/billing-service'
|
||||
|
||||
// Hardcoded VPC as requested
|
||||
const K8S_VPC = '81b2bd94-61dc-424b-a1ca-ca4c810ed4c4'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', message: 'Unauthorized: Please login to continue' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get input data from request
|
||||
const input = await request.json()
|
||||
if (!input) {
|
||||
return NextResponse.json({ status: 'error', message: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!input.cluster_label || !input.nodepools) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', message: 'Cluster label and nodepools are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get API key from environment
|
||||
const UTHO_API_KEY = process.env.UTHO_API_KEY
|
||||
if (!UTHO_API_KEY) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', message: 'API configuration missing' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Connect to MongoDB
|
||||
await connectDB()
|
||||
|
||||
// Get user data for billing
|
||||
const userData = await UserModel.findOne({ email: user.email })
|
||||
if (!userData) {
|
||||
return NextResponse.json({ status: 'error', message: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const requiredAmount = input.amount || 0
|
||||
|
||||
// Check balance only if amount > 0
|
||||
if (requiredAmount > 0) {
|
||||
const currentBalance = userData.balance || 0
|
||||
if (currentBalance < requiredAmount) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: `Insufficient balance. Required: ₹${requiredAmount}, Available: ₹${currentBalance}`,
|
||||
code: 'INSUFFICIENT_BALANCE',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare Utho payload for Kubernetes
|
||||
const uthoPayload = {
|
||||
dcslug: 'inmumbaizone2', // Hardcoded as requested
|
||||
cluster_label: input.cluster_label,
|
||||
cluster_version: input.cluster_version || '1.30.0-utho',
|
||||
nodepools: input.nodepools,
|
||||
vpc: K8S_VPC, // Hardcoded VPC
|
||||
network_type: 'publicprivate',
|
||||
cpumodel: 'amd',
|
||||
}
|
||||
|
||||
console.log('Sending to Utho Kubernetes API:', uthoPayload)
|
||||
|
||||
// Make API request to Utho Kubernetes
|
||||
const UTHO_API_URL = 'https://api.utho.com/v2/kubernetes/deploy'
|
||||
const response = await fetch(UTHO_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${UTHO_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(uthoPayload),
|
||||
})
|
||||
|
||||
const httpCode = response.status
|
||||
const deploymentSuccess = httpCode >= 200 && httpCode < 300
|
||||
const responseData = await response.json()
|
||||
|
||||
// Add status field and clusterId for consistency
|
||||
responseData.status = deploymentSuccess ? 'success' : 'error'
|
||||
if (deploymentSuccess && responseData.id) {
|
||||
responseData.clusterId = responseData.id
|
||||
}
|
||||
|
||||
console.log('Utho Kubernetes API response:', { httpCode, responseData })
|
||||
|
||||
// Process billing using the new comprehensive billing service
|
||||
try {
|
||||
const billingResult = await BillingService.processServiceDeployment({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
siliconId: userData.siliconId,
|
||||
},
|
||||
service: {
|
||||
name: `Kubernetes Cluster - ${input.cluster_label}`,
|
||||
type: 'kubernetes',
|
||||
id: responseData.clusterId || responseData.id,
|
||||
clusterId: responseData.clusterId || responseData.id,
|
||||
config: {
|
||||
cluster_label: input.cluster_label,
|
||||
cluster_version: input.cluster_version,
|
||||
nodepools: input.nodepools,
|
||||
dcslug: 'inmumbaizone2',
|
||||
vpc: K8S_VPC,
|
||||
network_type: 'publicprivate',
|
||||
cpumodel: 'amd',
|
||||
},
|
||||
},
|
||||
amount: requiredAmount,
|
||||
currency: 'INR',
|
||||
cycle: (input.cycle as any) || 'onetime',
|
||||
deploymentSuccess,
|
||||
deploymentResponse: responseData,
|
||||
metadata: {
|
||||
userAgent: request.headers.get('user-agent'),
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
|
||||
uthoPayload,
|
||||
httpCode,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Billing processed:', {
|
||||
billingId: billingResult.billing.billing_id,
|
||||
transactionId: billingResult.transaction?.transactionId,
|
||||
balanceUpdated: billingResult.balanceUpdated,
|
||||
})
|
||||
} catch (billingError) {
|
||||
console.error('Billing processing failed:', billingError)
|
||||
// Continue even if billing fails, but return error if it's balance-related
|
||||
if (billingError instanceof Error && billingError.message.includes('Insufficient balance')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: billingError.message,
|
||||
code: 'INSUFFICIENT_BALANCE',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(responseData, { status: httpCode })
|
||||
} catch (error) {
|
||||
console.error('Kubernetes deployment error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Internal server error',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GET endpoint for downloading kubeconfig
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', message: 'Unauthorized: Please login to continue' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const clusterId = searchParams.get('clusterId')
|
||||
|
||||
if (!clusterId) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', message: 'Cluster ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get API key from environment
|
||||
const UTHO_API_KEY = process.env.UTHO_API_KEY
|
||||
if (!UTHO_API_KEY) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', message: 'API configuration missing' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Download kubeconfig
|
||||
const KUBECONFIG_URL = `https://api.utho.com/v2/kubernetes/${clusterId}/download`
|
||||
const response = await fetch(KUBECONFIG_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${UTHO_API_KEY}`,
|
||||
Accept: 'application/yaml',
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const kubeconfig = await response.text()
|
||||
|
||||
// Return as downloadable file
|
||||
return new NextResponse(kubeconfig, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/yaml',
|
||||
'Content-Disposition': `attachment; filename="kubeconfig-${clusterId}.yaml"`,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
console.error('Kubeconfig download failed:', response.status, errorText)
|
||||
return NextResponse.json(
|
||||
{ status: 'error', message: 'Failed to download kubeconfig' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Kubeconfig download error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Internal server error',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
162
app/api/services/deploy-vpn/route.ts
Normal file
162
app/api/services/deploy-vpn/route.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import { User as UserModel } from '@/models/user'
|
||||
import BillingService from '@/lib/billing-service'
|
||||
|
||||
// Define your VPN endpoints
|
||||
const VPN_ENDPOINTS = {
|
||||
america: 'https://wireguard-vpn.3027622.siliconpin.com/vpn',
|
||||
europe: 'https://wireguard.vps20.siliconpin.com/vpn',
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get data from request body
|
||||
const { orderId, location, plan, amount } = await request.json()
|
||||
|
||||
if (!orderId) {
|
||||
return NextResponse.json({ error: 'Order ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!location || !VPN_ENDPOINTS[location as keyof typeof VPN_ENDPOINTS]) {
|
||||
return NextResponse.json({ error: 'Valid VPN location is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Connect to MongoDB
|
||||
await connectDB()
|
||||
|
||||
// Get user data for billing
|
||||
const userData = await UserModel.findOne({ email: user.email })
|
||||
if (!userData) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const requiredAmount = amount || 0
|
||||
|
||||
// Check balance only if amount > 0
|
||||
if (requiredAmount > 0) {
|
||||
const currentBalance = userData.balance || 0
|
||||
if (currentBalance < requiredAmount) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Insufficient balance. Required: ₹${requiredAmount}, Available: ₹${currentBalance}`,
|
||||
code: 'INSUFFICIENT_BALANCE',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Get environment variables
|
||||
const VPN_API_KEY = process.env.VPN_API_KEY
|
||||
if (!VPN_API_KEY) {
|
||||
return NextResponse.json({ error: 'VPN API configuration missing' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Get the endpoint for the selected location
|
||||
const endpoint = VPN_ENDPOINTS[location as keyof typeof VPN_ENDPOINTS]
|
||||
|
||||
// Make API request to the selected VPN endpoint
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': VPN_API_KEY,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
new: orderId.toString(),
|
||||
userId: user.id,
|
||||
plan,
|
||||
location,
|
||||
}),
|
||||
})
|
||||
console.log('VPN_API_KEY', VPN_API_KEY)
|
||||
// Handle non-200 responses
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text()
|
||||
throw new Error(`VPN API returned HTTP ${response.status}: ${errorData}`)
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
const vpnData = await response.json()
|
||||
const deploymentSuccess = response.ok && vpnData?.config
|
||||
|
||||
if (!deploymentSuccess) {
|
||||
throw new Error('Invalid response from VPN API: Missing config')
|
||||
}
|
||||
|
||||
// Process billing using the new comprehensive billing service
|
||||
try {
|
||||
const billingResult = await BillingService.processServiceDeployment({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
siliconId: userData.siliconId,
|
||||
},
|
||||
service: {
|
||||
name: `VPN Service - ${location}`,
|
||||
type: 'vpn',
|
||||
id: orderId.toString(),
|
||||
config: {
|
||||
orderId,
|
||||
location,
|
||||
plan,
|
||||
endpoint: endpoint,
|
||||
},
|
||||
},
|
||||
amount: requiredAmount,
|
||||
currency: 'INR',
|
||||
cycle: 'monthly',
|
||||
deploymentSuccess,
|
||||
deploymentResponse: vpnData,
|
||||
metadata: {
|
||||
userAgent: request.headers.get('user-agent'),
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
|
||||
vpnLocation: location,
|
||||
vpnPlan: plan,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('VPN billing processed:', {
|
||||
billingId: billingResult.billing.billing_id,
|
||||
transactionId: billingResult.transaction?.transactionId,
|
||||
balanceUpdated: billingResult.balanceUpdated,
|
||||
})
|
||||
} catch (billingError) {
|
||||
console.error('Billing processing failed:', billingError)
|
||||
// Continue even if billing fails, but return error if it's balance-related
|
||||
if (billingError instanceof Error && billingError.message.includes('Insufficient balance')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: billingError.message,
|
||||
code: 'INSUFFICIENT_BALANCE',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the VPN configuration
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: vpnData,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('VPN deployment error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'VPN deployment failed',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
108
app/api/services/download-hosting-conf/route.ts
Normal file
108
app/api/services/download-hosting-conf/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import { User as UserModel } from '@/models/user'
|
||||
import BillingService from '@/lib/billing-service'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get billing ID from query parameters
|
||||
const { searchParams } = new URL(request.url)
|
||||
const billingId = searchParams.get('billing_id')
|
||||
const amount = parseFloat(searchParams.get('amount') || '0')
|
||||
|
||||
if (!billingId) {
|
||||
return NextResponse.json({ error: 'Billing ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Connect to MongoDB
|
||||
await connectDB()
|
||||
|
||||
// Get user data for billing
|
||||
const userData = await UserModel.findOne({ email: user.email })
|
||||
if (!userData) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Mock hosting configuration (in production, this would come from database)
|
||||
const hostingConfig = {
|
||||
billing_id: billingId,
|
||||
siliconId: user.id,
|
||||
domain: `demo-${billingId}.siliconpin.com`,
|
||||
cp_url: `https://cp-${billingId}.siliconpin.com:2083`,
|
||||
panel: 'cPanel',
|
||||
user_id: `user_${billingId}`,
|
||||
password: `secure_password_${Date.now()}`,
|
||||
server_ip: '192.168.1.100',
|
||||
nameservers: ['ns1.siliconpin.com', 'ns2.siliconpin.com'],
|
||||
ftp_settings: {
|
||||
host: `ftp.demo-${billingId}.siliconpin.com`,
|
||||
username: `user_${billingId}`,
|
||||
port: 21,
|
||||
},
|
||||
database_settings: {
|
||||
host: 'localhost',
|
||||
prefix: `db_${billingId}_`,
|
||||
},
|
||||
ssl_certificate: {
|
||||
enabled: true,
|
||||
type: "Let's Encrypt",
|
||||
auto_renew: true,
|
||||
},
|
||||
}
|
||||
|
||||
const configJson = JSON.stringify(hostingConfig, null, 2)
|
||||
|
||||
// Process billing for hosting configuration download
|
||||
try {
|
||||
await BillingService.processServiceDeployment({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
siliconId: userData.siliconId,
|
||||
},
|
||||
service: {
|
||||
name: `Hosting Configuration - ${hostingConfig.domain}`,
|
||||
type: 'hosting',
|
||||
id: billingId,
|
||||
config: hostingConfig,
|
||||
},
|
||||
amount: amount,
|
||||
currency: 'INR',
|
||||
cycle: 'onetime',
|
||||
deploymentSuccess: true,
|
||||
deploymentResponse: { configDownloaded: true },
|
||||
metadata: {
|
||||
userAgent: request.headers.get('user-agent'),
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
|
||||
downloadType: 'hosting_config',
|
||||
domain: hostingConfig.domain,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Hosting config billing processed for:', billingId)
|
||||
} catch (billingError) {
|
||||
console.error('Billing processing failed:', billingError)
|
||||
// Continue with download even if billing fails
|
||||
}
|
||||
|
||||
// Return the config as a downloadable JSON file
|
||||
return new NextResponse(configJson, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': `attachment; filename="sp_credential_${billingId}.json"`,
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Download error:', error)
|
||||
return NextResponse.json({ error: 'Download failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
54
app/api/services/download-kubernetes/route.ts
Normal file
54
app/api/services/download-kubernetes/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get cluster ID from query parameters
|
||||
const { searchParams } = new URL(request.url)
|
||||
const clusterId = searchParams.get('cluster_id')
|
||||
|
||||
if (!clusterId) {
|
||||
return NextResponse.json({ error: 'Cluster ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Mock Kubernetes configuration
|
||||
const kubeConfig = `apiVersion: v1
|
||||
clusters:
|
||||
- cluster:
|
||||
certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURCVENDQWUyZ0F3SUJBZ0lJRWRtTFUzZUNCUXN3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TkRBeE1EUXhOekF6TXpCYUZ3MHpOREF4TURFeE56QXpNekJhTUJVeApFekFSQmdOVkJBTVRDbXQxWW1WeWJtVjBaWE13Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLLQEKQVFJREFRQUI=
|
||||
server: https://k8s-api.siliconpin.com:6443
|
||||
name: ${clusterId}
|
||||
contexts:
|
||||
- context:
|
||||
cluster: ${clusterId}
|
||||
user: ${clusterId}-admin
|
||||
name: ${clusterId}
|
||||
current-context: ${clusterId}
|
||||
kind: Config
|
||||
preferences: {}
|
||||
users:
|
||||
- name: ${clusterId}-admin
|
||||
user:
|
||||
client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t
|
||||
client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQ==`
|
||||
|
||||
// Return the config as a downloadable file
|
||||
return new NextResponse(kubeConfig, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="kubeconfig-${clusterId}.yaml"`,
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Download error:', error)
|
||||
return NextResponse.json({ error: 'Download failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
285
app/api/services/hire-developer/route.ts
Normal file
285
app/api/services/hire-developer/route.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import { User as UserModel } from '@/models/user'
|
||||
import { DeveloperRequest, IDeveloperRequest } from '@/models/developer-request'
|
||||
import { Transaction } from '@/models/transaction'
|
||||
import BillingService from '@/lib/billing-service'
|
||||
import { checkServiceAvailability } from '@/lib/system-settings'
|
||||
|
||||
// Schema for developer hire request
|
||||
const HireDeveloperSchema = z.object({
|
||||
planId: z.enum(['hourly', 'daily', 'monthly']),
|
||||
planName: z.string().min(1),
|
||||
planPrice: z.number().positive(),
|
||||
requirements: z.string().min(10, 'Requirements must be at least 10 characters').max(5000),
|
||||
contactInfo: z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Valid email is required'),
|
||||
phone: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
// Schema for response
|
||||
const HireDeveloperResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
requestId: z.string(),
|
||||
transactionId: z.string(),
|
||||
status: z.string(),
|
||||
message: z.string(),
|
||||
estimatedResponse: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
error: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
code: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check if developer hire service is enabled
|
||||
if (!(await checkServiceAvailability('developer'))) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Developer hire service is currently disabled by administrator',
|
||||
code: 'SERVICE_DISABLED',
|
||||
},
|
||||
},
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Authentication required', code: 'UNAUTHORIZED' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
await connectDB()
|
||||
|
||||
// Parse and validate request body
|
||||
const body = await request.json()
|
||||
const validatedData = HireDeveloperSchema.parse(body)
|
||||
|
||||
// Get user's current balance from database
|
||||
const userData = await UserModel.findOne({ email: user.email })
|
||||
if (!userData) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'User not found', code: 'USER_NOT_FOUND' },
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const currentBalance = userData.balance || 0
|
||||
|
||||
// Check if user has sufficient balance (minimum deposit required)
|
||||
const minimumDeposit = Math.min(validatedData.planPrice * 0.5, 10000) // 50% or max ₹10,000
|
||||
if (currentBalance < minimumDeposit) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: `Insufficient balance. Minimum deposit required: ₹${minimumDeposit}, Available: ₹${currentBalance}`,
|
||||
code: 'INSUFFICIENT_BALANCE',
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create developer request first
|
||||
const developerRequest = new DeveloperRequest({
|
||||
userId: userData._id,
|
||||
planId: validatedData.planId,
|
||||
planName: validatedData.planName,
|
||||
planPrice: validatedData.planPrice,
|
||||
requirements: validatedData.requirements,
|
||||
contactInfo: validatedData.contactInfo,
|
||||
status: 'pending',
|
||||
paymentStatus: 'pending', // Will be updated after billing
|
||||
transactionId: null, // Will be set after transaction is created
|
||||
})
|
||||
|
||||
// Save developer request first
|
||||
const savedRequest = await developerRequest.save()
|
||||
|
||||
// Process billing using the new comprehensive billing service
|
||||
try {
|
||||
const billingResult = await BillingService.processServiceDeployment({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
siliconId: userData.siliconId,
|
||||
},
|
||||
service: {
|
||||
name: `Developer Hire - ${validatedData.planName}`,
|
||||
type: 'developer_hire',
|
||||
id: savedRequest._id.toString(),
|
||||
config: {
|
||||
planId: validatedData.planId,
|
||||
planName: validatedData.planName,
|
||||
planPrice: validatedData.planPrice,
|
||||
requirements: validatedData.requirements,
|
||||
contactInfo: validatedData.contactInfo,
|
||||
},
|
||||
},
|
||||
amount: minimumDeposit,
|
||||
currency: 'INR',
|
||||
cycle: 'onetime',
|
||||
deploymentSuccess: true, // Developer hire request is always successful
|
||||
deploymentResponse: { requestId: savedRequest._id.toString() },
|
||||
metadata: {
|
||||
userAgent: request.headers.get('user-agent'),
|
||||
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
|
||||
depositAmount: minimumDeposit,
|
||||
fullPlanPrice: validatedData.planPrice,
|
||||
},
|
||||
})
|
||||
|
||||
// Update developer request with billing info
|
||||
await (DeveloperRequest as any).updateOne(
|
||||
{ _id: savedRequest._id },
|
||||
{
|
||||
$set: {
|
||||
paymentStatus: 'paid',
|
||||
transactionId: billingResult.transaction?._id,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
console.log('Developer hire billing processed:', {
|
||||
requestId: savedRequest._id,
|
||||
billingId: billingResult.billing.billing_id,
|
||||
transactionId: billingResult.transaction?.transactionId,
|
||||
})
|
||||
} catch (billingError) {
|
||||
console.error('Billing processing failed:', billingError)
|
||||
// Rollback developer request if billing fails
|
||||
await (DeveloperRequest as any).deleteOne({ _id: savedRequest._id })
|
||||
|
||||
if (billingError instanceof Error && billingError.message.includes('Insufficient balance')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: billingError.message,
|
||||
code: 'INSUFFICIENT_BALANCE',
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error('Failed to process payment')
|
||||
}
|
||||
|
||||
const responseData = {
|
||||
success: true,
|
||||
data: {
|
||||
requestId: savedRequest._id.toString(),
|
||||
transactionId: 'processed_via_billing',
|
||||
status: 'pending',
|
||||
message:
|
||||
'Your developer hire request has been submitted successfully! Our team will review your requirements and contact you within 24 hours.',
|
||||
estimatedResponse: '24 hours',
|
||||
},
|
||||
}
|
||||
|
||||
const validatedResponse = HireDeveloperResponseSchema.parse(responseData)
|
||||
return NextResponse.json(validatedResponse, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error('Developer hire error:', error)
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Invalid request data',
|
||||
code: 'VALIDATION_ERROR',
|
||||
details: error.issues,
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to process developer hire request', code: 'INTERNAL_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GET endpoint to fetch user's developer requests
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Authentication required', code: 'UNAUTHORIZED' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
await connectDB()
|
||||
|
||||
const userData = await UserModel.findOne({ email: user.email })
|
||||
if (!userData) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'User not found', code: 'USER_NOT_FOUND' },
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get user's developer requests
|
||||
const requests = await (DeveloperRequest as any)
|
||||
.find({ userId: userData._id })
|
||||
.sort({ createdAt: -1 })
|
||||
.populate('transactionId')
|
||||
.lean()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
requests,
|
||||
total: requests.length,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch developer requests:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to fetch requests', code: 'INTERNAL_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
52
app/api/startup-test/route.ts
Normal file
52
app/api/startup-test/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { testMongoConnection } from '@/lib/mongodb'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
console.log('🚀 Running startup connection tests...')
|
||||
|
||||
const mongoStatus = await testMongoConnection()
|
||||
|
||||
// Test Redis connection (basic check)
|
||||
let redisStatus = false
|
||||
try {
|
||||
// Import redis client
|
||||
const { redisClient } = await import('@/lib/redis')
|
||||
await redisClient.ping()
|
||||
console.log('✅ Redis connection test successful')
|
||||
redisStatus = true
|
||||
} catch (error) {
|
||||
console.error('❌ Redis connection test failed:', error)
|
||||
console.error('Redis is optional but recommended for session storage')
|
||||
}
|
||||
|
||||
const overallStatus = mongoStatus // Redis is optional, so we only require MongoDB
|
||||
|
||||
if (overallStatus) {
|
||||
console.log('🎉 All critical services are connected and ready!')
|
||||
} else {
|
||||
console.log('⚠️ Some services failed connection tests')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: overallStatus,
|
||||
services: {
|
||||
mongodb: mongoStatus,
|
||||
redis: redisStatus,
|
||||
},
|
||||
message: overallStatus
|
||||
? 'All critical services connected successfully'
|
||||
: 'Some services failed connection tests - check logs',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ Startup test failed:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Startup test failed',
|
||||
message: 'Check server logs for details',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
76
app/api/tags/route.ts
Normal file
76
app/api/tags/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import TopicModel from '@/models/topic'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const includeStats = searchParams.get('stats') === 'true'
|
||||
|
||||
// Get all published topics and extract tags
|
||||
const topics = await TopicModel.find({ isDraft: false })
|
||||
.select('tags')
|
||||
.lean()
|
||||
|
||||
if (!topics || topics.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: [],
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create a map to track tag usage
|
||||
const tagMap = new Map<string, { name: string; count: number }>()
|
||||
|
||||
topics.forEach((topic) => {
|
||||
topic.tags?.forEach((tag: { id?: string; name: string }) => {
|
||||
const tagName = tag.name
|
||||
const existing = tagMap.get(tagName.toLowerCase())
|
||||
|
||||
if (existing) {
|
||||
existing.count++
|
||||
} else {
|
||||
tagMap.set(tagName.toLowerCase(), {
|
||||
name: tagName,
|
||||
count: 1,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Convert to array and sort by usage count (descending)
|
||||
const tagsArray = Array.from(tagMap.values())
|
||||
.sort((a, b) => b.count - a.count)
|
||||
|
||||
// If stats are requested, include the count
|
||||
const result = tagsArray.map((tag, index) => ({
|
||||
id: `tag-${index + 1}`,
|
||||
name: tag.name,
|
||||
...(includeStats && { count: tag.count }),
|
||||
}))
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: result,
|
||||
meta: {
|
||||
totalTags: result.length,
|
||||
totalTopics: topics.length,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to fetch tags', code: 'SERVER_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
71
app/api/tools/openai-chat/route.ts
Normal file
71
app/api/tools/openai-chat/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// No authentication required for web-speech tool
|
||||
|
||||
const { message, systemPrompt } = await request.json()
|
||||
|
||||
if (!message || typeof message !== 'string') {
|
||||
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if OpenAI API key is configured
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
return NextResponse.json({ error: 'OpenAI API key not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Prepare messages for OpenAI
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
systemPrompt ||
|
||||
'You are a helpful AI assistant. Provide clear, concise, and helpful responses to user queries.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: message,
|
||||
},
|
||||
]
|
||||
|
||||
// Call OpenAI Chat Completions API
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-3.5-turbo',
|
||||
messages: messages,
|
||||
max_tokens: 1000,
|
||||
temperature: 0.7,
|
||||
top_p: 1,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text()
|
||||
console.error('OpenAI Chat API error:', errorData)
|
||||
return NextResponse.json({ error: 'AI processing failed' }, { status: 500 })
|
||||
}
|
||||
|
||||
const chatResult = await response.json()
|
||||
|
||||
if (!chatResult.choices || chatResult.choices.length === 0) {
|
||||
return NextResponse.json({ error: 'No response from AI' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
response: chatResult.choices[0].message.content,
|
||||
usage: chatResult.usage,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('OpenAI chat error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
166
app/api/topic-content-image/route.ts
Normal file
166
app/api/topic-content-image/route.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
import { uploadFile, moveToPermStorage, getFileUrl, generateUniqueFilename } from '@/lib/file-vault'
|
||||
|
||||
// POST /api/topic-content-image - Upload images for topic content (rich text editor)
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Authentication required for image uploads
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Authentication required', code: 'AUTH_REQUIRED' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const image = formData.get('file') as File
|
||||
|
||||
if (!image) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Image file is required', code: 'MISSING_IMAGE' },
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']
|
||||
if (!allowedTypes.includes(image.type)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Invalid file type. Only JPEG, PNG, GIF, and WebP are allowed',
|
||||
code: 'INVALID_FILE_TYPE',
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate file size (5MB limit)
|
||||
const maxSize = 5 * 1024 * 1024 // 5MB
|
||||
if (image.size > maxSize) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'File size too large. Maximum size is 5MB',
|
||||
code: 'FILE_TOO_LARGE',
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log('Processing topic content image upload for user:', user.id)
|
||||
|
||||
try {
|
||||
// Convert file to buffer (exactly like dev-portfolio)
|
||||
const bytes = await image.arrayBuffer()
|
||||
const buffer = Buffer.from(bytes)
|
||||
|
||||
// Upload directly to external API (no temp storage needed)
|
||||
const uploadResult = await uploadFile(buffer, image.name, image.type, user.id)
|
||||
const imageUrl = uploadResult.url
|
||||
const uniqueFilename = uploadResult.filename
|
||||
const permanentPath = uploadResult.url
|
||||
|
||||
console.log('Topic content image uploaded successfully:', {
|
||||
originalName: image.name,
|
||||
permanentPath,
|
||||
uploadedBy: user.id,
|
||||
})
|
||||
|
||||
// Return URL format expected by BlockNote editor
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
url: imageUrl, // BlockNote expects URL here
|
||||
fileName: uniqueFilename,
|
||||
path: permanentPath,
|
||||
size: image.size,
|
||||
type: image.type,
|
||||
uploadedBy: user.id,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to upload topic content image:', error)
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading topic content image:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to upload image', code: 'UPLOAD_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/topic-content-image - Get image metadata or URL by path (like dev-portfolio)
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Authentication required to access image metadata
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Authentication required', code: 'AUTH_REQUIRED' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const imagePath = searchParams.get('path')
|
||||
|
||||
if (!imagePath) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Image path parameter is required', code: 'MISSING_PATH' },
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get accessible URL for the image (like dev-portfolio)
|
||||
const imageUrl = await getFileUrl(imagePath)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
path: imagePath,
|
||||
url: imageUrl,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error serving topic content image:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to retrieve image', code: 'SERVER_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
261
app/api/topic/[id]/route.ts
Normal file
261
app/api/topic/[id]/route.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import TopicModel, { transformToTopic } from '@/models/topic'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
import { uploadFile, moveToPermStorage, getFileUrl, generateUniqueFilename } from '@/lib/file-vault'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Validation schema for topic updates
|
||||
const topicUpdateSchema = z.object({
|
||||
title: z.string().min(1).max(100).optional(),
|
||||
author: z.string().min(1).optional(),
|
||||
excerpt: z.string().min(1).max(200).optional(),
|
||||
content: z.string().min(1).optional(),
|
||||
contentRTE: z.unknown().optional(),
|
||||
contentImages: z.array(z.string()).optional(),
|
||||
tags: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string(),
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.optional(),
|
||||
featured: z.boolean().optional(),
|
||||
isDraft: z.boolean().optional(),
|
||||
coverImage: z.string().url().optional(),
|
||||
coverImageKey: z.string().optional(),
|
||||
})
|
||||
|
||||
// GET /api/topic/[id] - Get topic by ID
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
const topic = await TopicModel.findOne({ id }).lean()
|
||||
|
||||
if (!topic) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Topic not found', code: 'NOT_FOUND' },
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if topic is draft and user is not the owner
|
||||
if (topic.isDraft) {
|
||||
const user = await authMiddleware(request)
|
||||
if (!user || user.id !== topic.authorId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Topic not found', code: 'NOT_FOUND' },
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const transformedTopic = transformToTopic(topic)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: transformedTopic,
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error fetching topic:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to fetch topic', code: 'SERVER_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/topic/[id] - Update topic by ID
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
// Authentication required for updating topics
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Authentication required', code: 'AUTH_REQUIRED' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Find the topic first
|
||||
const existingTopic = await TopicModel.findOne({ id })
|
||||
|
||||
if (!existingTopic) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Topic not found', code: 'NOT_FOUND' },
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check ownership - users can only update their own topics
|
||||
if (existingTopic.authorId !== user.id) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'You can only edit your own topics', code: 'FORBIDDEN' },
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Validate request body
|
||||
const validatedData = topicUpdateSchema.parse(body)
|
||||
|
||||
console.log('Updating topic for user:', user.id, 'Topic ID:', id)
|
||||
|
||||
try {
|
||||
// Update the topic with new data
|
||||
Object.assign(existingTopic, validatedData)
|
||||
existingTopic.publishedAt = Date.now() // Update timestamp
|
||||
|
||||
const updatedTopic = await existingTopic.save()
|
||||
|
||||
console.log('Topic updated successfully:', updatedTopic.id)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: updatedTopic,
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to update topic:', error)
|
||||
|
||||
if (error.code === 11000) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'A topic with this slug already exists', code: 'DUPLICATE_SLUG' },
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating topic:', error)
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Validation failed',
|
||||
code: 'VALIDATION_ERROR',
|
||||
details: error.issues,
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to update topic', code: 'SERVER_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/topic/[id] - Delete topic by ID
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
// Authentication required for deleting topics
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Authentication required', code: 'AUTH_REQUIRED' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Find the topic first
|
||||
const existingTopic = await TopicModel.findOne({ id })
|
||||
|
||||
if (!existingTopic) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Topic not found', code: 'NOT_FOUND' },
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check ownership - users can only delete their own topics
|
||||
if (existingTopic.authorId !== user.id) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'You can only delete your own topics', code: 'FORBIDDEN' },
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log('Deleting topic for user:', user.id, 'Topic ID:', id)
|
||||
|
||||
try {
|
||||
await TopicModel.deleteOne({ id })
|
||||
|
||||
console.log('Topic deleted successfully:', id)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Topic deleted successfully',
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete topic:', error)
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting topic:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to delete topic', code: 'SERVER_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
427
app/api/topic/route.ts
Normal file
427
app/api/topic/route.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { randomUUID } from 'crypto'
|
||||
import TopicModel from '@/models/topic'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
import { uploadFile, moveToPermStorage, getFileUrl, generateUniqueFilename } from '@/lib/file-vault'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
console.log('Starting topic post processing')
|
||||
|
||||
// Debug authentication data
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const accessTokenCookie = request.cookies.get('accessToken')?.value
|
||||
console.log('🔍 Auth debug:', {
|
||||
hasAuthHeader: !!authHeader,
|
||||
authHeaderPrefix: authHeader?.substring(0, 20),
|
||||
hasAccessTokenCookie: !!accessTokenCookie,
|
||||
cookiePrefix: accessTokenCookie?.substring(0, 20),
|
||||
allCookies: Object.fromEntries(
|
||||
request.cookies.getAll().map((c) => [c.name, c.value?.substring(0, 20)])
|
||||
),
|
||||
})
|
||||
|
||||
// Authentication required
|
||||
const user = await authMiddleware(request)
|
||||
console.log('🔍 Auth result:', { user: user ? { id: user.id, email: user.email } : null })
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Authentication required', code: 'AUTH_REQUIRED' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const coverImage = formData.get('coverImage') as File
|
||||
const topicId = request.nextUrl.searchParams.get('topicId')
|
||||
console.log('Form data received', { topicId, hasCoverImage: !!coverImage })
|
||||
|
||||
// Get other form data with proper validation
|
||||
const title = formData.get('title') as string
|
||||
const author = formData.get('author') as string
|
||||
const excerpt = formData.get('excerpt') as string
|
||||
const content = formData.get('content') as string
|
||||
const contentRTE = formData.get('contentRTE') as string
|
||||
const featured = formData.get('featured') === 'true'
|
||||
// Get the LAST isDraft value (in case of duplicates)
|
||||
const allIsDraftValues = formData.getAll('isDraft')
|
||||
console.log('🐛 All isDraft values in FormData:', allIsDraftValues)
|
||||
const isDraft = allIsDraftValues[allIsDraftValues.length - 1] === 'true'
|
||||
console.log('🐛 Final isDraft value:', isDraft)
|
||||
|
||||
// Parse JSON fields safely (like dev-portfolio)
|
||||
let contentImages: string[] = []
|
||||
let tags: any[] = []
|
||||
|
||||
try {
|
||||
const contentImagesStr = formData.get('contentImages') as string
|
||||
contentImages = contentImagesStr ? JSON.parse(contentImagesStr) : []
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse contentImages, using empty array:', error)
|
||||
contentImages = []
|
||||
}
|
||||
|
||||
try {
|
||||
const tagsStr = formData.get('tags') as string
|
||||
tags = tagsStr ? JSON.parse(tagsStr) : []
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse tags, using empty array:', error)
|
||||
tags = []
|
||||
}
|
||||
|
||||
// Validate required fields (like dev-portfolio)
|
||||
if (!title?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Title is required', code: 'VALIDATION_ERROR' } },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!excerpt?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Excerpt is required', code: 'VALIDATION_ERROR' } },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!content?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Content is required', code: 'VALIDATION_ERROR' } },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!Array.isArray(tags) || tags.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'At least one tag is required', code: 'VALIDATION_ERROR' },
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const targetTopicId = topicId ? topicId : randomUUID()
|
||||
|
||||
// For existing topic, fetch the current data
|
||||
let existingTopic = null
|
||||
|
||||
if (topicId) {
|
||||
existingTopic = await TopicModel.findOne({ id: topicId })
|
||||
if (!existingTopic) {
|
||||
return NextResponse.json({ success: false, message: 'Topic not found' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
// Handle cover image upload exactly like dev-portfolio
|
||||
let tempImageResult = null
|
||||
if (coverImage && coverImage instanceof File) {
|
||||
try {
|
||||
console.log('🖼️ Processing cover image upload:', {
|
||||
name: coverImage.name,
|
||||
size: coverImage.size,
|
||||
type: coverImage.type,
|
||||
})
|
||||
|
||||
// Upload using external API
|
||||
try {
|
||||
// Convert file to buffer
|
||||
const bytes = await coverImage.arrayBuffer()
|
||||
const buffer = Buffer.from(bytes)
|
||||
|
||||
console.log('📤 Buffer created, starting upload...')
|
||||
|
||||
// Upload directly to external API (no temp storage needed)
|
||||
const uploadResult = await uploadFile(buffer, coverImage.name, coverImage.type, user.id)
|
||||
const coverImageUrl = uploadResult.url
|
||||
const permanentPath = uploadResult.url
|
||||
|
||||
tempImageResult = {
|
||||
coverImage: coverImageUrl,
|
||||
coverImageKey: permanentPath,
|
||||
}
|
||||
|
||||
console.log('✅ Cover image uploaded successfully:', { permanentPath })
|
||||
} catch (uploadError) {
|
||||
console.error('❌ Cover image upload failed:', uploadError)
|
||||
throw new Error(`Failed to upload cover image: ${uploadError.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing cover image:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const dataToSave = {
|
||||
id: targetTopicId,
|
||||
publishedAt: Date.now(),
|
||||
title,
|
||||
author,
|
||||
authorId: user.id, // Add user ownership
|
||||
excerpt,
|
||||
content,
|
||||
contentRTE: contentRTE ? JSON.parse(contentRTE) : [],
|
||||
contentImages,
|
||||
featured,
|
||||
tags,
|
||||
isDraft,
|
||||
// Use uploaded image or existing image for updates, require image for new topics like dev-portfolio
|
||||
coverImage:
|
||||
tempImageResult?.coverImage ||
|
||||
existingTopic?.coverImage ||
|
||||
'https://via.placeholder.com/800x400',
|
||||
coverImageKey: tempImageResult?.coverImageKey || existingTopic?.coverImageKey || '',
|
||||
}
|
||||
console.log('Preparing to save topic data', { targetTopicId })
|
||||
|
||||
let savedArticle
|
||||
try {
|
||||
if (existingTopic) {
|
||||
// Update existing topic
|
||||
console.log('Updating existing topic')
|
||||
Object.assign(existingTopic, dataToSave)
|
||||
savedArticle = await existingTopic.save()
|
||||
} else {
|
||||
// Create new topic
|
||||
console.log('Creating new topic')
|
||||
const newArticle = new TopicModel(dataToSave)
|
||||
savedArticle = await newArticle.save()
|
||||
}
|
||||
console.log('Topic saved successfully', { topicId: savedArticle.id })
|
||||
} catch (error) {
|
||||
console.error('Failed to save topic', { error })
|
||||
throw error
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: savedArticle,
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error processing topic post:', {
|
||||
error,
|
||||
message: error.message,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Failed to process topic post', error: error.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '10')
|
||||
const featured = searchParams.get('featured') === 'true'
|
||||
const isDraft = searchParams.get('isDraft')
|
||||
const search = searchParams.get('search')
|
||||
const tag = searchParams.get('tag')
|
||||
|
||||
// Build query (like dev-portfolio)
|
||||
const query: any = {}
|
||||
|
||||
// Featured filter
|
||||
if (featured) query.featured = true
|
||||
|
||||
// Draft filter (only if explicitly set)
|
||||
if (isDraft !== null) query.isDraft = isDraft === 'true'
|
||||
|
||||
// Search functionality (like dev-portfolio)
|
||||
if (search) {
|
||||
query.$or = [
|
||||
{ title: { $regex: search, $options: 'i' } },
|
||||
{ excerpt: { $regex: search, $options: 'i' } },
|
||||
{ content: { $regex: search, $options: 'i' } },
|
||||
]
|
||||
}
|
||||
|
||||
// Tag filtering (like dev-portfolio)
|
||||
if (tag) {
|
||||
query['tags.name'] = { $regex: tag, $options: 'i' }
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const [topics, total] = await Promise.all([
|
||||
TopicModel.find(query).sort({ publishedAt: -1 }).skip(skip).limit(limit),
|
||||
TopicModel.countDocuments(query),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
topics,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit),
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching topics:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to fetch topics', code: 'SERVER_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/topic - Update existing topic (like dev-portfolio)
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
console.log('Starting topic update processing')
|
||||
|
||||
// Authentication required
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Authentication required', code: 'AUTH_REQUIRED' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const topicId = formData.get('topicId') as string
|
||||
|
||||
if (!topicId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Topic ID is required for updates', code: 'MISSING_TOPIC_ID' },
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Find existing topic and check ownership
|
||||
const existingTopic = await TopicModel.findOne({ id: topicId })
|
||||
if (!existingTopic) {
|
||||
return NextResponse.json({ success: false, message: 'Topic not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check authorization - user can only update their own topics
|
||||
if (existingTopic.authorId !== user.id) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Not authorized to update this topic', code: 'UNAUTHORIZED' },
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Use the same logic as POST but for updates
|
||||
// This reuses the form processing logic from POST
|
||||
const url = new URL(request.url)
|
||||
url.searchParams.set('topicId', topicId)
|
||||
|
||||
// Create new request for POST handler with topicId parameter
|
||||
const updateRequest = new NextRequest(url.toString(), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
return POST(updateRequest)
|
||||
} catch (error) {
|
||||
console.error('Error updating topic post:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Failed to update topic post', error: error.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/topic - Delete topic (like dev-portfolio)
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
console.log('Starting topic deletion')
|
||||
|
||||
// Authentication required
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Authentication required', code: 'AUTH_REQUIRED' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const topicId = searchParams.get('id')
|
||||
|
||||
if (!topicId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Topic ID is required', code: 'MISSING_TOPIC_ID' } },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Find existing topic and check ownership
|
||||
const existingTopic = await TopicModel.findOne({ id: topicId })
|
||||
if (!existingTopic) {
|
||||
return NextResponse.json({ success: false, message: 'Topic not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check authorization - user can only delete their own topics
|
||||
if (existingTopic.authorId !== user.id) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Not authorized to delete this topic', code: 'UNAUTHORIZED' },
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log('Deleting topic:', { topicId, title: existingTopic.title?.substring(0, 50) })
|
||||
|
||||
// Delete the topic
|
||||
await TopicModel.deleteOne({ id: topicId })
|
||||
|
||||
console.log('Topic deleted successfully:', topicId)
|
||||
|
||||
// TODO: Implement image cleanup like dev-portfolio
|
||||
// if (existingTopic.coverImageKey && existingTopic.coverImageKey !== 'placeholder-key') {
|
||||
// await deleteFile(existingTopic.coverImageKey)
|
||||
// }
|
||||
// if (existingTopic.contentImages && existingTopic.contentImages.length > 0) {
|
||||
// for (const imagePath of existingTopic.contentImages) {
|
||||
// await deleteFile(imagePath)
|
||||
// }
|
||||
// }
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Topic deleted successfully',
|
||||
data: { id: topicId },
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error deleting topic:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Failed to delete topic', error: error.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
78
app/api/topic/slug/[slug]/route.ts
Normal file
78
app/api/topic/slug/[slug]/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import TopicModel, { transformToTopic } from '@/models/topic'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
|
||||
// GET /api/topic/slug/[slug] - Get topic by slug (for public viewing)
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
try {
|
||||
const { slug } = await params
|
||||
|
||||
if (!slug) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Slug parameter is required', code: 'MISSING_SLUG' },
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const topic = await TopicModel.findOne({ slug }).lean()
|
||||
|
||||
if (!topic) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Topic not found', code: 'NOT_FOUND' },
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if topic is draft and user is not the owner
|
||||
if (topic.isDraft) {
|
||||
const user = await authMiddleware(request)
|
||||
if (!user || user.id !== topic.authorId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Topic not found', code: 'NOT_FOUND' },
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const transformedTopic = transformToTopic(topic)
|
||||
|
||||
if (!transformedTopic) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to process topic data', code: 'PROCESSING_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: transformedTopic,
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error fetching topic by slug:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to fetch topic', code: 'SERVER_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
71
app/api/topics/[slug]/related/route.ts
Normal file
71
app/api/topics/[slug]/related/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import TopicModel, { transformToTopics } from '@/models/topic'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
try {
|
||||
await connectDB()
|
||||
|
||||
const { slug } = await params
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const limit = parseInt(searchParams.get('limit') || '2')
|
||||
|
||||
if (!slug) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Topic slug is required',
|
||||
code: 'SLUG_REQUIRED',
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// First, get the current post to find its ID and tags
|
||||
const currentPost = await TopicModel.findOne({
|
||||
slug,
|
||||
isDraft: false
|
||||
}).lean()
|
||||
|
||||
if (!currentPost) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: [],
|
||||
})
|
||||
}
|
||||
|
||||
// Find related posts by excluding the current post and sorting by publishedAt
|
||||
const relatedPosts = await TopicModel.find({
|
||||
id: { $ne: currentPost.id },
|
||||
isDraft: false,
|
||||
})
|
||||
.sort({ publishedAt: -1 })
|
||||
.limit(limit)
|
||||
.lean()
|
||||
|
||||
// Transform to proper format
|
||||
const transformedPosts = transformToTopics(relatedPosts)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: transformedPosts,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching related posts:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Failed to fetch related posts',
|
||||
code: 'SERVER_ERROR',
|
||||
},
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
79
app/api/topics/[slug]/route.ts
Normal file
79
app/api/topics/[slug]/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import TopicModel, { transformToTopic } from '@/models/topic'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
try {
|
||||
await connectDB()
|
||||
|
||||
const { slug } = await params
|
||||
|
||||
if (!slug) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Topic slug is required',
|
||||
code: 'SLUG_REQUIRED',
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Find the topic post by slug (only published posts)
|
||||
const post = await TopicModel.findOne({
|
||||
slug,
|
||||
isDraft: false
|
||||
}).lean()
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Topic post not found',
|
||||
code: 'POST_NOT_FOUND',
|
||||
},
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Transform to proper format
|
||||
const transformedPost = transformToTopic(post)
|
||||
|
||||
if (!transformedPost) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Failed to process topic post',
|
||||
code: 'PROCESSING_ERROR',
|
||||
},
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: transformedPost,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching topic post:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Failed to fetch topic post',
|
||||
code: 'SERVER_ERROR',
|
||||
},
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
75
app/api/topics/[slug]/view/route.ts
Normal file
75
app/api/topics/[slug]/view/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import TopicModel from '@/models/topic'
|
||||
|
||||
// Track topic view (increment view count and daily analytics)
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
try {
|
||||
const { slug } = await params
|
||||
|
||||
if (!slug) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Topic slug is required', code: 'MISSING_SLUG' },
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await connectDB()
|
||||
|
||||
// Get current date in YYYY-MM-DD format
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
// Find and update the topic
|
||||
const topic = await TopicModel.findOneAndUpdate(
|
||||
{ slug: slug, isDraft: false }, // Only track views for published posts
|
||||
{
|
||||
$inc: { views: 1 }, // Increment total views
|
||||
$push: {
|
||||
viewHistory: {
|
||||
$each: [{ date: today, count: 1 }],
|
||||
$slice: -30, // Keep only last 30 days
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
new: true,
|
||||
projection: { views: 1, slug: 1, title: 1, authorId: 1 } // Include authorId for cache invalidation
|
||||
}
|
||||
)
|
||||
|
||||
if (!topic) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Topic not found or is a draft', code: 'TOPIC_NOT_FOUND' },
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
slug: topic.slug,
|
||||
title: topic.title,
|
||||
views: topic.views,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('View tracking error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to track view', code: 'INTERNAL_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
142
app/api/topics/route.ts
Normal file
142
app/api/topics/route.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import TopicModel, { transformToTopics } from '@/models/topic'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
import { ZodError } from 'zod'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
await connectDB()
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '10')
|
||||
const search = searchParams.get('q') || ''
|
||||
const tag = searchParams.get('tag') || ''
|
||||
const authorId = searchParams.get('authorId') || '' // For filtering by user's topics
|
||||
const includeDrafts = searchParams.get('includeDrafts') === 'true' // For user's own topics
|
||||
const fetchAll = searchParams.get('fetchAll') // Admin panel support
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
// Build query based on parameters
|
||||
const query: any = {}
|
||||
|
||||
// Authentication check for private operations (drafts or admin)
|
||||
let user = null
|
||||
try {
|
||||
user = await authMiddleware(request)
|
||||
} catch (error) {
|
||||
// Non-authenticated request - that's okay for public topic listing
|
||||
}
|
||||
|
||||
// If requesting user's own topics with drafts, verify authentication
|
||||
if (includeDrafts && !user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Authentication required to view drafts', code: 'AUTH_REQUIRED' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Filter by author if specified
|
||||
if (authorId) {
|
||||
query.authorId = authorId
|
||||
}
|
||||
|
||||
// Handle draft filtering
|
||||
if (fetchAll === 'true') {
|
||||
// Admin panel - show all posts (drafts and published)
|
||||
// No draft filtering applied
|
||||
} else if (includeDrafts && user && (authorId === user.id || !authorId)) {
|
||||
// User requesting their own topics (including drafts)
|
||||
if (!authorId) {
|
||||
query.authorId = user.id
|
||||
}
|
||||
} else {
|
||||
// Public topic listing - only published topics
|
||||
query.isDraft = false
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
if (search) {
|
||||
query.$or = [
|
||||
{ title: { $regex: search, $options: 'i' } },
|
||||
{ excerpt: { $regex: search, $options: 'i' } },
|
||||
{ content: { $regex: search, $options: 'i' } },
|
||||
]
|
||||
}
|
||||
|
||||
// Filter by tag
|
||||
if (tag) {
|
||||
query['tags.name'] = { $regex: new RegExp(tag, 'i') }
|
||||
}
|
||||
|
||||
// Get total count with same filters
|
||||
const totalTopics = await TopicModel.countDocuments(query)
|
||||
|
||||
// Get paginated topics sorted by publishedAt
|
||||
const topics = await TopicModel.find(query)
|
||||
.sort({ publishedAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.lean()
|
||||
|
||||
if (!topics || topics.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: [],
|
||||
pagination: {
|
||||
total: 0,
|
||||
page,
|
||||
limit,
|
||||
totalPages: 0,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
// Transform the topics using our utility function
|
||||
const transformedTopics = transformToTopics(topics)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: transformedTopics,
|
||||
pagination: {
|
||||
total: totalTopics,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(totalTopics / limit),
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error fetching topics:', error)
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Data validation failed',
|
||||
code: 'VALIDATION_ERROR',
|
||||
details: error.issues,
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to fetch topics', code: 'SERVER_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
39
app/api/topics/tags/route.ts
Normal file
39
app/api/topics/tags/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import TopicModel from '@/models/topic'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
await connectDB()
|
||||
|
||||
// Get all unique tags from published topics
|
||||
const topics = await TopicModel.find({ isDraft: false }).select('tags').lean()
|
||||
const tags = new Set<string>()
|
||||
|
||||
topics.forEach((topic) => {
|
||||
topic.tags.forEach((tag: { id?: string; name: string }) => tags.add(tag.name))
|
||||
})
|
||||
|
||||
const uniqueTags = Array.from(tags).map((name, index) => ({
|
||||
id: String(index + 1),
|
||||
name
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: uniqueTags,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Failed to fetch tags',
|
||||
code: 'TAGS_FETCH_ERROR',
|
||||
},
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
147
app/api/transactions/route.ts
Normal file
147
app/api/transactions/route.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import { Transaction } from '@/models/transaction'
|
||||
import { Billing } from '@/models/billing'
|
||||
|
||||
// Query parameters schema
|
||||
const TransactionQuerySchema = z.object({
|
||||
page: z.string().optional().default('1'),
|
||||
limit: z.string().optional().default('10'),
|
||||
type: z.enum(['debit', 'credit', 'all']).optional().default('all'),
|
||||
service: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Authentication required', code: 'UNAUTHORIZED' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
await connectDB()
|
||||
|
||||
// Parse query parameters
|
||||
const { searchParams } = new URL(request.url)
|
||||
const queryParams = TransactionQuerySchema.parse({
|
||||
page: searchParams.get('page') || undefined,
|
||||
limit: searchParams.get('limit') || undefined,
|
||||
type: searchParams.get('type') || undefined,
|
||||
service: searchParams.get('service') || undefined,
|
||||
})
|
||||
|
||||
const page = parseInt(queryParams.page)
|
||||
const limit = Math.min(parseInt(queryParams.limit), 100) // Max 100 per page
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
// Build query filter
|
||||
const filter: any = { email: user.email }
|
||||
|
||||
if (queryParams.type !== 'all') {
|
||||
filter.type = queryParams.type
|
||||
}
|
||||
|
||||
if (queryParams.service) {
|
||||
filter.service = { $regex: queryParams.service, $options: 'i' }
|
||||
}
|
||||
|
||||
// Build billing filter
|
||||
const billingFilter: any = {
|
||||
$or: [{ user: user.email }, { siliconId: user.id }],
|
||||
}
|
||||
// console.log('billingFilter', user.email)
|
||||
if (queryParams.service) {
|
||||
billingFilter.service = { $regex: queryParams.service, $options: 'i' }
|
||||
}
|
||||
|
||||
// Get both transactions and billing records
|
||||
const [transactions, billingRecords, transactionCount, billingCount] = await Promise.all([
|
||||
Transaction.find(filter).sort({ createdAt: -1 }).lean().exec(),
|
||||
Billing.find(billingFilter).sort({ createdAt: -1 }).lean().exec(),
|
||||
Transaction.countDocuments(filter).exec(),
|
||||
Billing.countDocuments(billingFilter).exec(),
|
||||
])
|
||||
|
||||
// Transform billing records to transaction format
|
||||
const transformedBillings = billingRecords.map((billing: any) => ({
|
||||
_id: billing._id,
|
||||
transactionId: billing.billing_id,
|
||||
type: 'debit' as const,
|
||||
amount: billing.amount,
|
||||
service: billing.service,
|
||||
serviceId: billing.serviceId,
|
||||
description: billing.remarks || `${billing.service} - ${billing.cycle} billing`,
|
||||
status: billing.status === 'pending' ? 'pending' : 'completed',
|
||||
previousBalance: 0, // We don't have this data in billing
|
||||
newBalance: 0, // We don't have this data in billing
|
||||
createdAt: billing.createdAt,
|
||||
updatedAt: billing.updatedAt,
|
||||
email: billing.user,
|
||||
userId: billing.siliconId,
|
||||
}))
|
||||
|
||||
// Combine and sort all records
|
||||
const allRecords = [...transactions, ...transformedBillings].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
|
||||
// Apply type filter to combined records
|
||||
const filteredRecords =
|
||||
queryParams.type === 'all'
|
||||
? allRecords
|
||||
: allRecords.filter((record) => record.type === queryParams.type)
|
||||
|
||||
// Apply pagination to filtered records
|
||||
const totalCount = filteredRecords.length
|
||||
const paginatedRecords = filteredRecords.slice(skip, skip + limit)
|
||||
|
||||
const totalPages = Math.ceil(totalCount / limit)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
transactions: paginatedRecords,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get transactions error:', error)
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Invalid query parameters',
|
||||
code: 'VALIDATION_ERROR',
|
||||
details: error.format(),
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to fetch transactions', code: 'INTERNAL_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
102
app/api/upload/confirm/route.ts
Normal file
102
app/api/upload/confirm/route.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
import { moveToPermStorage, generateUniqueFilename, deleteFile } from '@/lib/file-vault'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Confirm upload request validation
|
||||
const confirmSchema = z.object({
|
||||
tempPath: z.string().min(1, 'Temporary path is required'),
|
||||
permanentFolder: z.string().optional().default('uploads'),
|
||||
filename: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { tempPath, permanentFolder, filename } = confirmSchema.parse(body)
|
||||
|
||||
// Validate temp path format
|
||||
if (!tempPath.startsWith('temp/')) {
|
||||
return NextResponse.json({ error: 'Invalid temporary path' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Generate permanent path
|
||||
const originalFilename = tempPath.split('/').pop() || 'file'
|
||||
const finalFilename = filename ? generateUniqueFilename(filename) : originalFilename
|
||||
const permanentPath = `${permanentFolder}/${finalFilename}`
|
||||
|
||||
// Move file from temp to permanent storage
|
||||
await moveToPermStorage(tempPath, permanentPath)
|
||||
|
||||
// TODO: Save file metadata to database
|
||||
// This would include:
|
||||
// - permanentPath
|
||||
// - originalFilename
|
||||
// - uploadedBy (user.id)
|
||||
// - uploadedAt
|
||||
// - fileSize
|
||||
// - mimeType
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
permanentPath,
|
||||
filename: finalFilename,
|
||||
folder: permanentFolder,
|
||||
confirmedBy: user.id,
|
||||
confirmedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Upload confirmation error:', error)
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request parameters', details: error.issues },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// Delete temporary file (cleanup)
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tempPath = searchParams.get('path')
|
||||
|
||||
if (!tempPath) {
|
||||
return NextResponse.json({ error: 'Temporary path is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate temp path format
|
||||
if (!tempPath.startsWith('temp/')) {
|
||||
return NextResponse.json({ error: 'Invalid temporary path' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Delete temporary file
|
||||
await deleteFile(tempPath)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Temporary file deleted successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Temporary file deletion error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
129
app/api/upload/route.ts
Normal file
129
app/api/upload/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
import { uploadFile, validateFileType, validateFileSize } from '@/lib/file-vault'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Allowed file types and sizes
|
||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||
|
||||
const ALLOWED_DOCUMENT_TYPES = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/plain',
|
||||
]
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
// Upload request validation
|
||||
const uploadSchema = z.object({
|
||||
type: z.enum(['image', 'document']).optional().default('image'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse form data
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File
|
||||
const typeParam = formData.get('type') as string
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate request parameters
|
||||
const { type } = uploadSchema.parse({ type: typeParam })
|
||||
|
||||
// Determine allowed file types based on upload type
|
||||
const allowedTypes = type === 'image' ? ALLOWED_IMAGE_TYPES : ALLOWED_DOCUMENT_TYPES
|
||||
|
||||
// Validate file type
|
||||
if (!validateFileType(file.type, allowedTypes)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Invalid file type. Allowed types: ${allowedTypes.join(', ')}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (!validateFileSize(file.size, MAX_FILE_SIZE)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `File size too large. Max size: ${MAX_FILE_SIZE / (1024 * 1024)}MB`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Convert file to buffer
|
||||
const bytes = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(bytes)
|
||||
|
||||
// Upload file to external API
|
||||
const uploadResult = await uploadFile(buffer, file.name, file.type, user.id)
|
||||
|
||||
// Return upload information
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
tempPath: uploadResult.url, // Use URL as tempPath for compatibility
|
||||
filename: uploadResult.filename,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
uploadedBy: user.id,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
url: uploadResult.url, // Also include the direct URL
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request parameters', details: error.issues },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// Get file information
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const filePath = searchParams.get('path')
|
||||
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: 'File path is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// TODO: Add file metadata retrieval from database
|
||||
// For now, return basic info
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
path: filePath,
|
||||
// Additional metadata would be retrieved from database
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('File retrieval error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
79
app/api/user/balance/route.ts
Normal file
79
app/api/user/balance/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { authMiddleware } from '@/lib/auth-middleware'
|
||||
import connectDB from '@/lib/mongodb'
|
||||
import { User as UserModel } from '@/models/user'
|
||||
|
||||
// Schema for balance response
|
||||
const BalanceResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.object({
|
||||
balance: z.number(),
|
||||
currency: z.string().default('INR'),
|
||||
lastUpdated: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
// Get user's current balance
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Authenticate user
|
||||
const user = await authMiddleware(request)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Authentication required', code: 'UNAUTHORIZED' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
await connectDB()
|
||||
|
||||
// Get user's current balance from database
|
||||
const userData = await UserModel.findOne({ email: user.email })
|
||||
if (!userData) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'User not found', code: 'USER_NOT_FOUND' },
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const responseData = {
|
||||
success: true,
|
||||
data: {
|
||||
balance: userData.balance || 0,
|
||||
currency: 'INR',
|
||||
lastUpdated: userData.updatedAt?.toISOString() || new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
// Validate response format
|
||||
const validatedResponse = BalanceResponseSchema.parse(responseData)
|
||||
return NextResponse.json(validatedResponse, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error('Balance API error:', error)
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Invalid response format', code: 'VALIDATION_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'Failed to fetch balance', code: 'INTERNAL_ERROR' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user