REFACTOR(repo): simplify project structure
- Move services/nextjs to nextjs/ - Move deploy/docker/Dockerfile.prod to Dockerfile - Add GitHub Actions workflows (ci.yml, build.yml) - Remove deploy/, services/, scripts/ folders
This commit is contained in:
61
nextjs/app/api/auth/login/route.ts
Normal file
61
nextjs/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { authenticateUser, generateToken } from '@/shared/lib/auth'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { email, password } = await req.json()
|
||||
|
||||
// Validation
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email and password are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
const user = await authenticateUser(email, password)
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid credentials' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
})
|
||||
|
||||
// Create response with user data
|
||||
const response = NextResponse.json({
|
||||
message: 'Login successful',
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
},
|
||||
})
|
||||
|
||||
// Set auth token in cookie (HttpOnly for security)
|
||||
response.cookies.set('auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
22
nextjs/app/api/auth/logout/route.ts
Normal file
22
nextjs/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Create response
|
||||
const response = NextResponse.json({
|
||||
message: 'Logout successful',
|
||||
})
|
||||
|
||||
// Clear auth token cookie
|
||||
response.cookies.delete('auth-token')
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
54
nextjs/app/api/auth/me/route.ts
Normal file
54
nextjs/app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyToken } from '@/shared/lib/auth'
|
||||
import { db } from '@/shared/lib/db'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Get token from cookie
|
||||
const token = req.cookies.get('auth-token')?.value
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const payload = verifyToken(token)
|
||||
|
||||
if (!payload) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid token' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(user)
|
||||
} catch (error) {
|
||||
console.error('Get user error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
76
nextjs/app/api/auth/register/route.ts
Normal file
76
nextjs/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createUser, generateToken } from '@/shared/lib/auth'
|
||||
import { db } from '@/shared/lib/db'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { email, password, name } = await req.json()
|
||||
|
||||
// Validation
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email and password are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password must be at least 6 characters' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await db.user.findUnique({
|
||||
where: { email },
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User already exists' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create user
|
||||
const user = await createUser(email, password, name)
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
})
|
||||
|
||||
// Create response with user data
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
message: 'User created successfully',
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
}
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
|
||||
// Set auth token in cookie (HttpOnly for security)
|
||||
response.cookies.set('auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
49
nextjs/app/api/documents/[id]/public/route.ts
Normal file
49
nextjs/app/api/documents/[id]/public/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { db } from '@/shared/lib/db'
|
||||
|
||||
// GET /api/documents/[id]/public - Get a public document (no auth required)
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const id = url.pathname.split('/')[3] // Extract ID from /api/documents/[id]/public
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Document ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 공개된 문서만 조회 (인증 없이)
|
||||
const document = await db.document.findFirst({
|
||||
where: {
|
||||
id,
|
||||
isPublished: true, // 공개된 문서만
|
||||
isArchived: false,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Document not found or not published' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(document)
|
||||
} catch (error) {
|
||||
console.error('Error fetching public document:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch document' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
202
nextjs/app/api/documents/[id]/route.ts
Normal file
202
nextjs/app/api/documents/[id]/route.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { withAuth } from '@/shared/lib/middleware'
|
||||
import { db } from '@/shared/lib/db'
|
||||
import { deleteFile } from '@/shared/lib/s3'
|
||||
|
||||
// GET /api/documents/[id] - Get a specific document
|
||||
export const GET = withAuth(async (req: NextRequest, userId: string) => {
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const id = url.pathname.split('/').pop()
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Document ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Allow viewing both archived and non-archived documents
|
||||
const document = await db.document.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Document not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(document)
|
||||
} catch (error) {
|
||||
console.error('Error fetching document:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch document' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/documents/[id] - Update a specific document
|
||||
export const PUT = withAuth(async (req: NextRequest, userId: string) => {
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const id = url.pathname.split('/').pop()
|
||||
const { title, content, icon, cover, isPublished } = await req.json()
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Document ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if document exists and belongs to user
|
||||
const existingDocument = await db.document.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
isArchived: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (!existingDocument) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Document not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const document = await db.document.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(title && { title }),
|
||||
...(content && { content }),
|
||||
...(icon !== undefined && { icon }),
|
||||
...(cover !== undefined && { cover }),
|
||||
...(isPublished !== undefined && { isPublished }),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(document)
|
||||
} catch (error) {
|
||||
console.error('Error updating document:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update document' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Helper function to extract image paths from document content
|
||||
async function extractImagePaths(content: any): Promise<string[]> {
|
||||
if (!content) return [];
|
||||
|
||||
const paths: string[] = [];
|
||||
|
||||
try {
|
||||
const parsed = typeof content === 'string' ? JSON.parse(content) : content;
|
||||
|
||||
// Recursive function to traverse the document structure
|
||||
const traverse = (node: any) => {
|
||||
if (!node) return;
|
||||
|
||||
// Check if this is an imageUpload node with a path
|
||||
if (node.type === 'imageUpload' && node.attrs?.path) {
|
||||
paths.push(node.attrs.path);
|
||||
}
|
||||
|
||||
// Recursively check content and children
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
node.content.forEach(traverse);
|
||||
}
|
||||
if (node.children && Array.isArray(node.children)) {
|
||||
node.children.forEach(traverse);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if parsed is an object with content
|
||||
if (parsed.content) {
|
||||
traverse(parsed);
|
||||
} else if (Array.isArray(parsed)) {
|
||||
parsed.forEach(traverse);
|
||||
} else if (parsed.type) {
|
||||
traverse(parsed);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing document content:', error);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
// DELETE /api/documents/[id] - Archive a specific document
|
||||
export const DELETE = withAuth(async (req: NextRequest, userId: string) => {
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const id = url.pathname.split('/').pop()
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Document ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if document exists and belongs to user
|
||||
const existingDocument = await db.document.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
isArchived: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (!existingDocument) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Document not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Extract and delete all image files from the document
|
||||
const imagePaths = await extractImagePaths(existingDocument.content);
|
||||
if (imagePaths.length > 0) {
|
||||
console.log(`Deleting ${imagePaths.length} image files from document ${id}`);
|
||||
|
||||
// Delete all image files
|
||||
await Promise.allSettled(
|
||||
imagePaths.map(path => {
|
||||
try {
|
||||
return deleteFile(path);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete image file ${path}:`, error);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Archive the document instead of deleting it
|
||||
const document = await db.document.update({
|
||||
where: { id },
|
||||
data: {
|
||||
isArchived: true,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: 'Document archived successfully' })
|
||||
} catch (error) {
|
||||
console.error('Error archiving document:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to archive document' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
84
nextjs/app/api/documents/route.ts
Normal file
84
nextjs/app/api/documents/route.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { withAuth } from '@/shared/lib/middleware'
|
||||
import { db } from '@/shared/lib/db'
|
||||
|
||||
// GET /api/documents - Get all documents for the authenticated user
|
||||
export const GET = withAuth(async (req: NextRequest, userId: string) => {
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const folderId = url.searchParams.get('folderId')
|
||||
|
||||
const documents = await db.document.findMany({
|
||||
where: {
|
||||
userId,
|
||||
isArchived: false,
|
||||
...(folderId && { folderId }),
|
||||
},
|
||||
include: {
|
||||
folder: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
icon: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(documents)
|
||||
} catch (error) {
|
||||
console.error('Error fetching documents:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch documents' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/documents - Create a new document
|
||||
export const POST = withAuth(async (req: NextRequest, userId: string) => {
|
||||
try {
|
||||
const { title, parentId, folderId } = await req.json()
|
||||
|
||||
if (!title) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Title is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const document = await db.document.create({
|
||||
data: {
|
||||
title,
|
||||
userId,
|
||||
parentId: parentId || null,
|
||||
folderId: folderId || null,
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Start writing...',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(document, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating document:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create document' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
46
nextjs/app/api/download-url/route.ts
Normal file
46
nextjs/app/api/download-url/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { S3_CONFIG, s3Client } from '@/shared/config/s3';
|
||||
|
||||
// 파일 다운로드 URL 생성
|
||||
const generateDownloadUrl = async (fileKey: string): Promise<string> => {
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: S3_CONFIG.BUCKET_NAME,
|
||||
Key: fileKey,
|
||||
});
|
||||
|
||||
const downloadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||
|
||||
return downloadUrl;
|
||||
} catch (error) {
|
||||
throw new Error('다운로드 URL 생성에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { fileKey } = body;
|
||||
|
||||
if (!fileKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'fileKey가 필요합니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const downloadUrl = await generateDownloadUrl(fileKey);
|
||||
|
||||
return NextResponse.json({
|
||||
downloadUrl,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || '다운로드 URL 생성에 실패했습니다.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
264
nextjs/app/api/folders/[id]/route.ts
Normal file
264
nextjs/app/api/folders/[id]/route.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { withAuth } from '@/shared/lib/middleware'
|
||||
import { db } from '@/shared/lib/db'
|
||||
|
||||
// GET /api/folders/[id] - Get a specific folder
|
||||
export const GET = withAuth(async (req: NextRequest, userId: string) => {
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const id = url.pathname.split('/').pop()
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Folder ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const folder = await db.folder.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
isArchived: false,
|
||||
},
|
||||
include: {
|
||||
documents: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
icon: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc',
|
||||
},
|
||||
},
|
||||
children: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
documents: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
children: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!folder) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Folder not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(folder)
|
||||
} catch (error) {
|
||||
console.error('Error fetching folder:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch folder' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/folders/[id] - Update a specific folder
|
||||
export const PUT = withAuth(async (req: NextRequest, userId: string) => {
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const id = url.pathname.split('/').pop()
|
||||
const { name, parentId, icon, color } = await req.json()
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Folder ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if folder exists and belongs to user
|
||||
const existingFolder = await db.folder.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
isArchived: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (!existingFolder) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Folder not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const folder = await db.folder.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name && { name }),
|
||||
...(parentId !== undefined && { parentId: parentId || null }),
|
||||
...(icon !== undefined && { icon }),
|
||||
...(color !== undefined && { color }),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
documents: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
icon: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc',
|
||||
},
|
||||
},
|
||||
children: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
documents: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
children: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(folder)
|
||||
} catch (error) {
|
||||
console.error('Error updating folder:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update folder' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/folders/[id] - Archive a specific folder
|
||||
export const DELETE = withAuth(async (req: NextRequest, userId: string) => {
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const id = url.pathname.split('/').pop()
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Folder ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if folder exists and belongs to user
|
||||
const existingFolder = await db.folder.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
isArchived: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (!existingFolder) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Folder not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Recursively archive the folder and all its contents
|
||||
const archiveFolderRecursively = async (folderId: string, tx: any) => {
|
||||
// Archive all documents in this folder
|
||||
await tx.document.updateMany({
|
||||
where: {
|
||||
folderId: folderId,
|
||||
isArchived: false,
|
||||
},
|
||||
data: {
|
||||
isArchived: true,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Get all child folders
|
||||
const childFolders = await tx.folder.findMany({
|
||||
where: {
|
||||
parentId: folderId,
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Recursively archive all child folders
|
||||
for (const childFolder of childFolders) {
|
||||
await archiveFolderRecursively(childFolder.id, tx)
|
||||
}
|
||||
|
||||
// Archive this folder
|
||||
await tx.folder.update({
|
||||
where: { id: folderId },
|
||||
data: {
|
||||
isArchived: true,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Archive the folder and all its contents
|
||||
await db.$transaction(async (tx) => {
|
||||
await archiveFolderRecursively(id, tx)
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: 'Folder archived successfully' })
|
||||
} catch (error) {
|
||||
console.error('Error archiving folder:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to archive folder' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
154
nextjs/app/api/folders/route.ts
Normal file
154
nextjs/app/api/folders/route.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { withAuth } from '@/shared/lib/middleware'
|
||||
import { db } from '@/shared/lib/db'
|
||||
|
||||
// GET /api/folders - Get all folders for the authenticated user
|
||||
export const GET = withAuth(async (req: NextRequest, userId: string) => {
|
||||
try {
|
||||
const folders = await db.folder.findMany({
|
||||
where: {
|
||||
userId,
|
||||
isArchived: false,
|
||||
},
|
||||
include: {
|
||||
documents: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
icon: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
children: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
documents: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
children: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
documents: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
children: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(folders)
|
||||
} catch (error) {
|
||||
console.error('Error fetching folders:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch folders' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/folders - Create a new folder
|
||||
export const POST = withAuth(async (req: NextRequest, userId: string) => {
|
||||
try {
|
||||
const { name, parentId, icon, color } = await req.json()
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Folder name is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const folder = await db.folder.create({
|
||||
data: {
|
||||
name,
|
||||
userId,
|
||||
parentId: parentId || null,
|
||||
icon: icon || '📁',
|
||||
color: color || null,
|
||||
},
|
||||
include: {
|
||||
documents: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
icon: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
children: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
documents: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
children: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
documents: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
children: {
|
||||
where: {
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(folder, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating folder:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create folder' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
27
nextjs/app/api/health/route.ts
Normal file
27
nextjs/app/api/health/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/shared/lib/db';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Check database connectivity
|
||||
await db.$queryRaw`SELECT 1`;
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV,
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
database: 'connected'
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
error: 'Database connection failed'
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
22
nextjs/app/api/metadata/route.ts
Normal file
22
nextjs/app/api/metadata/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { extractBookmarkMetadata } from '@/shared/lib/metadata-extractor';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { url } = await request.json();
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const metadata = await extractBookmarkMetadata(url);
|
||||
|
||||
return NextResponse.json(metadata);
|
||||
} catch (error) {
|
||||
console.error('Error in metadata API:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch metadata' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
105
nextjs/app/api/templates/route.ts
Normal file
105
nextjs/app/api/templates/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/shared/lib/prisma';
|
||||
import { withAuth } from '@/shared/lib/middleware';
|
||||
|
||||
async function createTemplate(request: NextRequest, userId: string) {
|
||||
try {
|
||||
const { name, description, category, title, content } = await request.json();
|
||||
|
||||
if (!name || !title || !content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Template name, title, and content are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify user exists in database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Save template to database (always private to the user)
|
||||
const template = await prisma.template.create({
|
||||
data: {
|
||||
name,
|
||||
description: description || '',
|
||||
category: category || 'General',
|
||||
title,
|
||||
content,
|
||||
isPublic: false, // Always private
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Template created:', template);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
template,
|
||||
message: 'Template created successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating template:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create template' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withAuth(createTemplate);
|
||||
|
||||
async function getTemplates(request: NextRequest, userId: string) {
|
||||
try {
|
||||
// Verify user exists in database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch only user's own templates
|
||||
const templates = await prisma.template.findMany({
|
||||
where: {
|
||||
userId // Only user's own templates
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
category: true,
|
||||
title: true,
|
||||
content: true,
|
||||
isPublic: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
userId: true,
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ templates });
|
||||
} catch (error) {
|
||||
console.error('Error fetching templates:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch templates' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withAuth(getTemplates);
|
||||
57
nextjs/app/api/upload-url/route.ts
Normal file
57
nextjs/app/api/upload-url/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { S3_CONFIG, s3Client } from '@/shared/config/s3';
|
||||
|
||||
// 파일 업로드 URL 생성
|
||||
const generateUploadUrl = async (
|
||||
fileName: string,
|
||||
fileType: string,
|
||||
folder?: string
|
||||
): Promise<{ uploadUrl: string; fileKey: string }> => {
|
||||
try {
|
||||
const fileKey = folder ? `${folder}/${Date.now()}-${fileName}` : `${Date.now()}-${fileName}`;
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: S3_CONFIG.BUCKET_NAME,
|
||||
Key: fileKey,
|
||||
ContentType: fileType,
|
||||
});
|
||||
|
||||
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||
|
||||
return {
|
||||
uploadUrl,
|
||||
fileKey,
|
||||
};
|
||||
} catch (error: any) {
|
||||
throw new Error(`업로드 URL 생성에 실패했습니다: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { fileName, fileType, folder } = body;
|
||||
|
||||
if (!fileName || !fileType) {
|
||||
return NextResponse.json(
|
||||
{ error: 'fileName과 fileType이 필요합니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { uploadUrl, fileKey } = await generateUploadUrl(fileName, fileType, folder || 'jotion-uploads');
|
||||
|
||||
return NextResponse.json({
|
||||
uploadUrl,
|
||||
fileKey,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message || '업로드 URL 생성에 실패했습니다.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
177
nextjs/app/api/upload/route.ts
Normal file
177
nextjs/app/api/upload/route.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { uploadFileServer, deleteFile } from '@/shared/lib/s3';
|
||||
import { cookies } from 'next/headers';
|
||||
import { verifyToken } from '@/shared/lib/auth';
|
||||
|
||||
// File size limits (in bytes)
|
||||
const MAX_FILE_SIZE = {
|
||||
image: 10 * 1024 * 1024, // 10MB
|
||||
audio: 50 * 1024 * 1024, // 50MB
|
||||
document: 20 * 1024 * 1024, // 20MB
|
||||
};
|
||||
|
||||
// Allowed file types
|
||||
const ALLOWED_TYPES = {
|
||||
image: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'],
|
||||
audio: ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/mp4', 'audio/webm'],
|
||||
document: [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
],
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Verify authentication - check cookie
|
||||
const cookieStore = await cookies();
|
||||
const cookieToken = cookieStore.get('auth-token')?.value;
|
||||
|
||||
const token = cookieToken;
|
||||
|
||||
if (!token) {
|
||||
console.error('[Upload] No token found in Authorization header or cookie');
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
if (!decoded) {
|
||||
console.error('[Upload] Token verification failed');
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid token' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
console.log('[Upload] Token verified successfully for user:', decoded.userId);
|
||||
|
||||
// Parse form data
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
const fileType = formData.get('type') as 'images' | 'audio' | 'documents';
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No file provided' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine folder based on file type
|
||||
let folder: 'images' | 'audio' | 'documents' = 'documents';
|
||||
let maxSize = MAX_FILE_SIZE.document;
|
||||
let allowedTypes = ALLOWED_TYPES.document;
|
||||
|
||||
if (fileType === 'images') {
|
||||
folder = 'images';
|
||||
maxSize = MAX_FILE_SIZE.image;
|
||||
allowedTypes = ALLOWED_TYPES.image;
|
||||
} else if (fileType === 'audio') {
|
||||
folder = 'audio';
|
||||
maxSize = MAX_FILE_SIZE.audio;
|
||||
allowedTypes = ALLOWED_TYPES.audio;
|
||||
} else if (file.type.startsWith('image/')) {
|
||||
folder = 'images';
|
||||
maxSize = MAX_FILE_SIZE.image;
|
||||
allowedTypes = ALLOWED_TYPES.image;
|
||||
} else if (file.type.startsWith('audio/')) {
|
||||
folder = 'audio';
|
||||
maxSize = MAX_FILE_SIZE.audio;
|
||||
allowedTypes = ALLOWED_TYPES.audio;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > maxSize) {
|
||||
return NextResponse.json(
|
||||
{ error: `File size exceeds maximum limit of ${maxSize / 1024 / 1024}MB` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `File type ${file.type} is not allowed` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Upload file to S3
|
||||
const { url, path } = await uploadFileServer(file, file.name, file.type, folder);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url,
|
||||
path,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
fileType: file.type,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to upload file' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/upload - Delete a file from storage
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
// Verify authentication
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
const cookieStore = await cookies();
|
||||
const cookieToken = cookieStore.get('auth-token')?.value;
|
||||
|
||||
const token = authHeader?.startsWith('Bearer ')
|
||||
? authHeader.substring(7)
|
||||
: cookieToken;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
if (!decoded) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid token' },
|
||||
{ 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 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete file from S3
|
||||
await deleteFile(filePath);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'File deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete file error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to delete file' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user