428 lines
13 KiB
TypeScript
428 lines
13 KiB
TypeScript
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 }
|
|
)
|
|
}
|
|
}
|