CHORE(merge): merge from develop
- Initial setup and all features from develop branch - Includes: auth, deploy, docker, style fixes - K3S deployment configuration
This commit is contained in:
132
nextjs/app/api/announcements/[id]/route.ts
Normal file
132
nextjs/app/api/announcements/[id]/route.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { s3Client, S3_CONFIG } from '@/const';
|
||||
import { generateSignedUrl } from '@/lib/s3';
|
||||
|
||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
|
||||
|
||||
// GET: 특정 공지사항 조회
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id: idParam } = await params;
|
||||
const id = parseInt(idParam);
|
||||
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '유효하지 않은 ID입니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const announcement = await prisma.announcement.update({
|
||||
where: { id },
|
||||
data: { viewCount: { increment: 1 } },
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
userName: true,
|
||||
},
|
||||
},
|
||||
files: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 이미지 파일에 signedUrl 추가
|
||||
const filesWithUrls = await Promise.all(
|
||||
(announcement.files || []).map(async (file) => {
|
||||
const ext = file.fileName.split('.').pop()?.toLowerCase();
|
||||
const isImage = IMAGE_EXTENSIONS.includes(ext || '');
|
||||
return {
|
||||
...file,
|
||||
signedUrl: isImage ? await generateSignedUrl(file.fileKey) : undefined,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
...announcement,
|
||||
files: filesWithUrls,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Get announcement error:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '공지사항 조회에 실패했습니다.';
|
||||
return NextResponse.json(
|
||||
{ success: false, message: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: 공지사항 삭제
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id: idParam } = await params;
|
||||
const id = parseInt(idParam);
|
||||
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '유효하지 않은 ID입니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 1. 공지사항 및 첨부파일 조회
|
||||
const announcement = await prisma.announcement.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
files: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!announcement) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '공지사항을 찾을 수 없습니다.' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. S3에서 첨부파일 삭제
|
||||
if (announcement.files && announcement.files.length > 0) {
|
||||
await Promise.all(
|
||||
announcement.files.map((file) =>
|
||||
s3Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: S3_CONFIG.BUCKET_NAME,
|
||||
Key: file.fileKey,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 3. DB에서 삭제 (files는 onDelete: Cascade로 자동 삭제됨)
|
||||
await prisma.announcement.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '삭제되었습니다.',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Delete announcement error:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '삭제에 실패했습니다.';
|
||||
return NextResponse.json(
|
||||
{ success: false, message: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
96
nextjs/app/api/announcements/route.ts
Normal file
96
nextjs/app/api/announcements/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getPaginationParams, createPaginatedResponse } from '@/lib/utils';
|
||||
|
||||
// GET: 공지사항 조회 (pagination 지원)
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const { page, limit, skip } = getPaginationParams(searchParams, 10);
|
||||
|
||||
const [announcements, total] = await Promise.all([
|
||||
prisma.announcement.findMany({
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
userName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.announcement.count(),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...createPaginatedResponse(announcements, total, page, limit),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Get announcements error:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '공지사항 조회에 실패했습니다.';
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: 새로운 공지사항 생성
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { title, content, isImportant, authorId, files } = body;
|
||||
|
||||
if (!title || !authorId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '필수 필드가 누락되었습니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const announcement = await prisma.announcement.create({
|
||||
data: {
|
||||
title,
|
||||
content: content || "",
|
||||
isImportant: isImportant || false,
|
||||
authorId,
|
||||
files: files && files.length > 0 ? {
|
||||
create: files.map((file: { fileKey: string; fileName: string; fileSize?: number; mimeType?: string }) => ({
|
||||
fileKey: file.fileKey,
|
||||
fileName: file.fileName,
|
||||
fileSize: file.fileSize,
|
||||
mimeType: file.mimeType,
|
||||
}))
|
||||
} : undefined,
|
||||
},
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
userName: true,
|
||||
},
|
||||
},
|
||||
files: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: announcement,
|
||||
}, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error('Create announcement error:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '공지사항 생성에 실패했습니다.';
|
||||
return NextResponse.json(
|
||||
{ success: false, message: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
4
nextjs/app/api/auth/[...nextauth]/route.ts
Normal file
4
nextjs/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
|
||||
31
nextjs/app/api/auth/me/route.ts
Normal file
31
nextjs/app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "로그인 필요", user: null },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: parseInt(session.user.id),
|
||||
userId: session.user.userId,
|
||||
userName: session.user.name,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Get user error:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "서버 오류가 발생했습니다.", user: null },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
95
nextjs/app/api/auth/signup/route.ts
Normal file
95
nextjs/app/api/auth/signup/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const {
|
||||
userId,
|
||||
userPassword,
|
||||
userCheckPassword,
|
||||
userName,
|
||||
userPhone,
|
||||
authCode,
|
||||
} = await req.json();
|
||||
|
||||
// 유효성 검사
|
||||
if (!userId || !userPassword || !userCheckPassword || !userName || !userPhone || !authCode) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "필수 정보를 모두 입력해주세요." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 승인번호 검사
|
||||
if (authCode !== process.env.CODE) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "승인번호가 올바르지 않습니다." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 비밀번호 확인
|
||||
if (userPassword !== userCheckPassword) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "비밀번호가 일치하지 않습니다." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 중복 체크: 아이디
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "이미 사용 중인 아이디입니다." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 중복 체크: 전화번호
|
||||
const existingPhone = await prisma.user.findUnique({
|
||||
where: { userPhone },
|
||||
});
|
||||
|
||||
if (existingPhone) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "이미 등록된 전화번호입니다." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// 비밀번호 해싱
|
||||
const hashedPassword = await bcrypt.hash(userPassword, 10);
|
||||
|
||||
// 새로운 유저 생성
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
userId,
|
||||
userPassword: hashedPassword,
|
||||
userName,
|
||||
userPhone,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
userId: newUser.userId,
|
||||
userName: newUser.userName,
|
||||
},
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Signup error:", err);
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "서버 오류가 발생했습니다." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
79
nextjs/app/api/disciple-videos/reorder/route.ts
Normal file
79
nextjs/app/api/disciple-videos/reorder/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// PUT: 영상 순서 변경 (두 비디오의 order만 교환)
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { videoId1, videoId2 } = body;
|
||||
|
||||
// 입력 검증
|
||||
if (!videoId1 || !videoId2) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "두 개의 비디오 ID가 필요합니다." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (videoId1 === videoId2) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "동일한 비디오는 교환할 수 없습니다." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 두 비디오 조회
|
||||
const [video1, video2] = await Promise.all([
|
||||
prisma.discipleVideo.findUnique({ where: { id: Number(videoId1) } }),
|
||||
prisma.discipleVideo.findUnique({ where: { id: Number(videoId2) } }),
|
||||
]);
|
||||
|
||||
if (!video1 || !video2) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "비디오를 찾을 수 없습니다." },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 같은 stage/step인지 확인
|
||||
if (video1.stage !== video2.stage || video1.step !== video2.step) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "같은 stage/step의 비디오만 교환할 수 있습니다." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.discipleVideo.update({
|
||||
where: { id: video1.id },
|
||||
data: { order: -1 }, // 임시 음수 값 (unique constraint 회피)
|
||||
}),
|
||||
prisma.discipleVideo.update({
|
||||
where: { id: video2.id },
|
||||
data: { order: video1.order },
|
||||
}),
|
||||
prisma.discipleVideo.update({
|
||||
where: { id: video1.id },
|
||||
data: { order: video2.order },
|
||||
}),
|
||||
]);
|
||||
|
||||
// 업데이트된 stage/step의 모든 비디오 반환
|
||||
const updatedVideos = await prisma.discipleVideo.findMany({
|
||||
where: { stage: video1.stage, step: video1.step },
|
||||
orderBy: { order: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: updatedVideos,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error reordering disciple videos:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "제자훈련 영상 순서 변경에 실패했습니다.";
|
||||
return NextResponse.json(
|
||||
{ success: false, message: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
148
nextjs/app/api/disciple-videos/route.ts
Normal file
148
nextjs/app/api/disciple-videos/route.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isValidYouTubeUrl, getYouTubeThumbnailUrl, getYouTubeEmbedUrl } from "@/lib/utils/youtube";
|
||||
|
||||
// GET: 모든 제자훈련 영상 또는 특정 stage 영상 가져오기
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const stage = searchParams.get('stage');
|
||||
|
||||
if (stage) {
|
||||
const videos = await prisma.discipleVideo.findMany({
|
||||
where: { stage },
|
||||
orderBy: [
|
||||
{ step: 'asc' },
|
||||
{ order: 'desc' },
|
||||
],
|
||||
});
|
||||
// 썸네일 및 embed URL 추가
|
||||
const videosWithUrls = videos.map(video => ({
|
||||
...video,
|
||||
thumbnailUrl: getYouTubeThumbnailUrl(video.videoUrl),
|
||||
embedUrl: getYouTubeEmbedUrl(video.videoUrl),
|
||||
}));
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: videosWithUrls,
|
||||
});
|
||||
}
|
||||
|
||||
const videos = await prisma.discipleVideo.findMany({
|
||||
orderBy: [
|
||||
{ stage: 'asc' },
|
||||
{ step: 'asc' },
|
||||
{ order: 'desc' },
|
||||
],
|
||||
});
|
||||
// 썸네일 및 embed URL 추가
|
||||
const videosWithUrls = videos.map(video => ({
|
||||
...video,
|
||||
thumbnailUrl: getYouTubeThumbnailUrl(video.videoUrl),
|
||||
embedUrl: getYouTubeEmbedUrl(video.videoUrl),
|
||||
}));
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: videosWithUrls,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching disciple videos:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "제자훈련 영상 조회에 실패했습니다.";
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: 새 영상 추가
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { stage, step, videoUrl } = body;
|
||||
|
||||
if (!stage || typeof stage !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "stage가 유효하지 않습니다." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!videoUrl || typeof videoUrl !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "영상 URL이 유효하지 않습니다." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// YouTube URL 유효성 검사
|
||||
if (!isValidYouTubeUrl(videoUrl)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "유효한 YouTube URL이 아닙니다." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// stage/step 내 기존 영상 확인하여 최고 order 값 가져오기
|
||||
const existingVideo = await prisma.discipleVideo.findFirst({
|
||||
where: { stage, step: step || null },
|
||||
orderBy: { order: 'desc' },
|
||||
});
|
||||
|
||||
// 새 영상은 현재 최고 order + 1로 설정 (맨 앞에 추가)
|
||||
const maxOrder = existingVideo?.order ?? 0;
|
||||
const newOrder = maxOrder + 1;
|
||||
|
||||
const newVideo = await prisma.discipleVideo.create({
|
||||
data: {
|
||||
stage,
|
||||
step: step || null,
|
||||
videoUrl,
|
||||
order: newOrder,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: newVideo,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error creating disciple video:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "제자훈련 영상 생성에 실패했습니다.";
|
||||
return NextResponse.json(
|
||||
{ success: false, message: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: 영상 삭제
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "영상 ID가 유효하지 않습니다." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.discipleVideo.delete({
|
||||
where: { id: Number(id) },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "영상이 삭제되었습니다."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error deleting disciple video:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "제자훈련 영상 삭제에 실패했습니다.";
|
||||
return NextResponse.json(
|
||||
{ success: false, message: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
32
nextjs/app/api/files/download-url/route.ts
Normal file
32
nextjs/app/api/files/download-url/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { generateSignedUrl } from '@/lib/s3';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { fileKey, fileName } = body;
|
||||
|
||||
if (!fileKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'fileKey가 필요합니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const downloadUrl = await generateSignedUrl(fileKey, { fileName });
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
downloadUrl,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '다운로드 URL 생성에 실패했습니다.';
|
||||
return NextResponse.json(
|
||||
{ success: false, message: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
72
nextjs/app/api/files/upload-url/route.ts
Normal file
72
nextjs/app/api/files/upload-url/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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 '@/const';
|
||||
import mime from 'mime-types';
|
||||
|
||||
// 파일 업로드 URL 생성
|
||||
const generateUploadUrl = async (
|
||||
fileName: string,
|
||||
fileType: string,
|
||||
folder?: string
|
||||
): Promise<{ uploadUrl: string; fileKey: string }> => {
|
||||
try {
|
||||
const fileKey = folder ? `Jaejadle/${folder}/${Date.now()}-${fileName}` : `Jaejadle/${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 (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '알 수 없는 오류';
|
||||
throw new Error(`업로드 URL 생성에 실패했습니다: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { fileName, fileType } = body;
|
||||
let { folder = 'uploads' } = body;
|
||||
|
||||
// folder에서 시작/끝 슬래시 제거
|
||||
if (folder) {
|
||||
folder = folder.replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
if (!fileName) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'fileName이 필요합니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// fileType이 없으면 파일 확장자로부터 MIME type 추론
|
||||
const contentType = fileType || mime.lookup(fileName) || 'application/octet-stream';
|
||||
|
||||
const { uploadUrl, fileKey } = await generateUploadUrl(fileName, contentType, folder);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
uploadUrl,
|
||||
fileKey,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '업로드 URL 생성에 실패했습니다.';
|
||||
return NextResponse.json(
|
||||
{ success: false, message: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
158
nextjs/app/api/gallery/[id]/route.ts
Normal file
158
nextjs/app/api/gallery/[id]/route.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { s3Client, S3_CONFIG } from '@/const';
|
||||
import { generateSignedUrl } from '@/lib/s3';
|
||||
|
||||
interface GalleryImage {
|
||||
id: number;
|
||||
fileKey: string;
|
||||
postId: number;
|
||||
order: number;
|
||||
aspectRatio: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface GalleryTextBlock {
|
||||
id: number;
|
||||
postId: number;
|
||||
content: string;
|
||||
order: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
type ContentItem =
|
||||
| { type: 'image'; data: GalleryImage & { displayUrl: string } }
|
||||
| { type: 'text'; data: GalleryTextBlock };
|
||||
|
||||
// GET: 갤러리 포스트 상세 조회
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const postId = parseInt(id, 10);
|
||||
|
||||
if (isNaN(postId)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '유효하지 않은 ID입니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const post = await prisma.galleryPost.findUnique({
|
||||
where: { id: postId },
|
||||
include: {
|
||||
images: {
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
textBlocks: {
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '갤러리를 찾을 수 없습니다.' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 이미지에 displayUrl 추가
|
||||
const imagesWithUrls = await Promise.all(
|
||||
post.images.map(async (img) => ({
|
||||
...img,
|
||||
displayUrl: await generateSignedUrl(img.fileKey),
|
||||
}))
|
||||
);
|
||||
|
||||
// 이미지와 텍스트 블록을 order 순서로 정렬하여 반환
|
||||
const sortedContent: ContentItem[] = [
|
||||
...imagesWithUrls.map((img) => ({ type: 'image' as const, data: img })),
|
||||
...(post.textBlocks || []).map((text) => ({ type: 'text' as const, data: text })),
|
||||
].sort((a, b) => a.data.order - b.data.order);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
...post,
|
||||
images: imagesWithUrls,
|
||||
sortedContent,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Get gallery post error:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '갤러리 조회에 실패했습니다.';
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: 갤러리 포스트 삭제
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const postId = parseInt(id, 10);
|
||||
|
||||
if (isNaN(postId)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '유효하지 않은 ID입니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 1. 포스트와 이미지 정보 먼저 조회
|
||||
const post = await prisma.galleryPost.findUnique({
|
||||
where: { id: postId },
|
||||
include: {
|
||||
images: true,
|
||||
textBlocks: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '갤러리를 찾을 수 없습니다.' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 2. S3에서 이미지 삭제
|
||||
await Promise.all(
|
||||
post.images.map((image: { fileKey: string }) =>
|
||||
s3Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: S3_CONFIG.BUCKET_NAME,
|
||||
Key: image.fileKey,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// 3. DB에서 포스트 삭제 (cascade로 이미지도 삭제됨)
|
||||
await prisma.galleryPost.delete({
|
||||
where: { id: postId },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '갤러리가 삭제되었습니다.',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Delete gallery post error:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '갤러리 삭제에 실패했습니다.';
|
||||
return NextResponse.json(
|
||||
{ success: false, message: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
135
nextjs/app/api/gallery/route.ts
Normal file
135
nextjs/app/api/gallery/route.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getPaginationParams, createPaginatedResponse } from '@/lib/utils';
|
||||
import { generateSignedUrl } from '@/lib/s3';
|
||||
|
||||
interface GalleryItem {
|
||||
type: 'image' | 'text';
|
||||
fileKey?: string;
|
||||
content?: string;
|
||||
order: number;
|
||||
aspectRatio?: number | null;
|
||||
}
|
||||
|
||||
// GET: 갤러리 포스트 목록 조회 (pagination 지원)
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const { page, limit, skip } = getPaginationParams(searchParams, 12);
|
||||
|
||||
const [posts, total] = await Promise.all([
|
||||
prisma.galleryPost.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
images: {
|
||||
take: 1,
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.galleryPost.count(),
|
||||
]);
|
||||
|
||||
// 썸네일 URL 생성
|
||||
const postsWithThumbnails = await Promise.all(
|
||||
posts.map(async (post) => ({
|
||||
...post,
|
||||
thumbnailUrl: post.images[0]
|
||||
? await generateSignedUrl(post.images[0].fileKey)
|
||||
: undefined,
|
||||
}))
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...createPaginatedResponse(postsWithThumbnails, total, page, limit),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Get gallery posts error:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '갤러리 조회에 실패했습니다.';
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: 새로운 갤러리 포스트 생성
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { title, content, items } = body;
|
||||
|
||||
if (!title) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '제목이 필요합니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '최소 1개 이상의 콘텐츠가 필요합니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 이미지가 최소 1개는 있어야 함
|
||||
const hasImage = items.some((item: GalleryItem) => item.type === 'image');
|
||||
if (!hasImage) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '최소 1개 이상의 이미지가 필요합니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// items를 order 순서대로 정렬
|
||||
const sortedItems = [...items].sort((a, b) => a.order - b.order);
|
||||
|
||||
const post = await prisma.galleryPost.create({
|
||||
data: {
|
||||
title,
|
||||
content: content || '',
|
||||
images: {
|
||||
create: sortedItems
|
||||
.filter((item: GalleryItem) => item.type === 'image')
|
||||
.map((item: GalleryItem) => ({
|
||||
fileKey: item.fileKey!,
|
||||
order: item.order,
|
||||
aspectRatio: item.aspectRatio || null,
|
||||
})),
|
||||
},
|
||||
textBlocks: {
|
||||
create: sortedItems
|
||||
.filter((item: GalleryItem) => item.type === 'text')
|
||||
.map((item: GalleryItem) => ({
|
||||
content: item.content!,
|
||||
order: item.order,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
images: {
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
textBlocks: {
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: post,
|
||||
}, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error('Create gallery post error:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '갤러리 생성에 실패했습니다.';
|
||||
return NextResponse.json(
|
||||
{ success: false, message: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
79
nextjs/app/api/worship/reorder/route.ts
Normal file
79
nextjs/app/api/worship/reorder/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// PUT: 영상 순서 변경 (두 비디오의 order만 교환)
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { videoId1, videoId2 } = body;
|
||||
|
||||
// 입력 검증
|
||||
if (!videoId1 || !videoId2) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "두 개의 비디오 ID가 필요합니다." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (videoId1 === videoId2) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "동일한 비디오는 교환할 수 없습니다." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 두 비디오 조회
|
||||
const [video1, video2] = await Promise.all([
|
||||
prisma.worshipVideo.findUnique({ where: { id: Number(videoId1) } }),
|
||||
prisma.worshipVideo.findUnique({ where: { id: Number(videoId2) } }),
|
||||
]);
|
||||
|
||||
if (!video1 || !video2) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "비디오를 찾을 수 없습니다." },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 같은 카테고리인지 확인
|
||||
if (video1.category !== video2.category) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "같은 카테고리의 비디오만 교환할 수 있습니다." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.worshipVideo.update({
|
||||
where: { id: video1.id },
|
||||
data: { order: -1 }, // 임시 음수 값 (unique constraint 회피)
|
||||
}),
|
||||
prisma.worshipVideo.update({
|
||||
where: { id: video2.id },
|
||||
data: { order: video1.order },
|
||||
}),
|
||||
prisma.worshipVideo.update({
|
||||
where: { id: video1.id },
|
||||
data: { order: video2.order },
|
||||
}),
|
||||
]);
|
||||
|
||||
// 업데이트된 카테고리의 모든 비디오 반환
|
||||
const updatedVideos = await prisma.worshipVideo.findMany({
|
||||
where: { category: video1.category },
|
||||
orderBy: { order: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: updatedVideos,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error reordering worship videos:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "예배 영상 순서 변경에 실패했습니다.";
|
||||
return NextResponse.json(
|
||||
{ success: false, message: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
151
nextjs/app/api/worship/route.ts
Normal file
151
nextjs/app/api/worship/route.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isValidYouTubeUrl, getYouTubeThumbnailUrl, getYouTubeEmbedUrl } from "@/lib/utils/youtube";
|
||||
|
||||
// GET: 모든 예배 영상 또는 특정 카테고리 영상 가져오기
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const category = searchParams.get('category');
|
||||
|
||||
if (category) {
|
||||
const videos = await prisma.worshipVideo.findMany({
|
||||
where: { category },
|
||||
orderBy: { order: 'desc' },
|
||||
});
|
||||
// 썸네일 및 embed URL 추가
|
||||
const videosWithUrls = videos.map(video => ({
|
||||
...video,
|
||||
thumbnailUrl: getYouTubeThumbnailUrl(video.videoUrl),
|
||||
embedUrl: getYouTubeEmbedUrl(video.videoUrl),
|
||||
}));
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: videosWithUrls,
|
||||
});
|
||||
}
|
||||
|
||||
const videos = await prisma.worshipVideo.findMany({
|
||||
orderBy: [
|
||||
{ category: 'asc' },
|
||||
{ order: 'desc' },
|
||||
],
|
||||
});
|
||||
// 썸네일 및 embed URL 추가
|
||||
const videosWithUrls = videos.map(video => ({
|
||||
...video,
|
||||
thumbnailUrl: getYouTubeThumbnailUrl(video.videoUrl),
|
||||
embedUrl: getYouTubeEmbedUrl(video.videoUrl),
|
||||
}));
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: videosWithUrls,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching worship videos:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "예배 영상 조회에 실패했습니다.";
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: 새 영상 추가
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { category, videoUrl } = body;
|
||||
|
||||
if (!category || typeof category !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "카테고리가 유효하지 않습니다." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!videoUrl || typeof videoUrl !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "영상 URL이 유효하지 않습니다." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// YouTube URL 유효성 검사
|
||||
if (!isValidYouTubeUrl(videoUrl)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "유효한 YouTube URL이 아닙니다." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 카테고리 내 기존 영상 확인 (order 내림차순)
|
||||
const existingVideos = await prisma.worshipVideo.findMany({
|
||||
where: { category },
|
||||
orderBy: { order: 'desc' },
|
||||
});
|
||||
|
||||
// 기존 영상이 9개 이상이면 order가 가장 낮은(마지막) 영상 삭제
|
||||
if (existingVideos.length >= 9) {
|
||||
const videoToDelete = existingVideos[existingVideos.length - 1];
|
||||
await prisma.worshipVideo.delete({
|
||||
where: { id: videoToDelete.id },
|
||||
});
|
||||
}
|
||||
|
||||
// 새 영상은 현재 최고 order + 1로 설정 (맨 앞에 추가)
|
||||
const maxOrder = existingVideos.length > 0 ? existingVideos[0].order : 0;
|
||||
const newOrder = maxOrder + 1;
|
||||
|
||||
const newVideo = await prisma.worshipVideo.create({
|
||||
data: {
|
||||
category,
|
||||
videoUrl,
|
||||
order: newOrder,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: newVideo,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error creating worship video:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "예배 영상 생성에 실패했습니다.";
|
||||
return NextResponse.json(
|
||||
{ success: false, message: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: 영상 삭제
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "영상 ID가 유효하지 않습니다." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.worshipVideo.delete({
|
||||
where: { id: Number(id) },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "영상이 삭제되었습니다."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error deleting worship video:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "예배 영상 삭제에 실패했습니다.";
|
||||
return NextResponse.json(
|
||||
{ success: false, message: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user