CHORE(merge): merge from develop
Some checks failed
Build Docker Image / build-and-push (push) Has been cancelled
CI / lint-and-build (push) Has been cancelled

- Initial setup and all features from develop branch
- Includes: auth, deploy, docker, style fixes
- K3S deployment configuration
This commit is contained in:
2026-01-06 17:29:16 +09:00
parent b4ce36ba3b
commit f78454c2a1
159 changed files with 18365 additions and 774 deletions

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

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

View File

@@ -0,0 +1,4 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

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

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

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

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

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

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

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

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

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

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