내부 api 삭제

This commit is contained in:
2025-11-24 22:50:28 +09:00
parent 74eee0a3c0
commit eaec13e386
14 changed files with 342 additions and 1863 deletions

View File

@@ -1,565 +0,0 @@
# API 사용 가이드
이 문서는 데이터베이스에 데이터를 생성하는 API의 사용 방법을 설명합니다.
## 📋 목차
1. [기본 설정](#기본-설정)
2. [사용자 API](#1-사용자-api)
3. [교육과정 API](#2-교육과정-api)
4. [강좌 API](#3-강좌-api)
5. [공지사항 API](#4-공지사항-api)
6. [에러 처리](#에러-처리)
7. [실전 예제](#실전-예제)
---
## 기본 설정
### 환경 변수
`.env` 파일에 데이터베이스 연결 정보가 설정되어 있어야 합니다:
```env
DATABASE_URL="postgresql://user:password@localhost:5432/dbname"
```
### API 기본 URL
- 개발 환경: `http://localhost:3000/api`
- 프로덕션: `https://your-domain.com/api`
---
## 1. 사용자 API
### POST /api/users - 사용자 생성
새로운 사용자를 생성합니다.
#### 요청
```typescript
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'user@example.com',
password: 'hashed_password_here', // 실제로는 해시화된 비밀번호
name: '홍길동',
phone: '010-1234-5678', // 선택사항
gender: 'M', // 선택사항: 'M' 또는 'F'
birthdate: '1990-01-01', // 선택사항: YYYY-MM-DD 형식
role: 'LEARNER', // 선택사항: 'LEARNER' 또는 'ADMIN' (기본값: 'LEARNER')
status: 'ACTIVE', // 선택사항: 'ACTIVE' 또는 'INACTIVE' (기본값: 'ACTIVE')
}),
});
const data = await response.json();
```
#### 성공 응답 (201)
```json
{
"message": "사용자가 성공적으로 생성되었습니다.",
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"name": "홍길동",
"phone": "010-1234-5678",
"gender": "M",
"birthdate": "1990-01-01T00:00:00.000Z",
"role": "LEARNER",
"status": "ACTIVE",
"joinDate": "2024-11-21T00:00:00.000Z",
"createdAt": "2024-11-21T00:00:00.000Z",
"updatedAt": "2024-11-21T00:00:00.000Z"
}
}
```
#### 에러 응답
**400 Bad Request** - 필수 필드 누락
```json
{
"error": "이메일, 비밀번호, 이름은 필수입니다."
}
```
**409 Conflict** - 이메일 중복
```json
{
"error": "이미 존재하는 이메일입니다."
}
```
### GET /api/users - 사용자 목록 조회
사용자 목록을 조회합니다. 필터링 및 페이지네이션을 지원합니다.
#### 요청
```typescript
// 전체 사용자 조회
const response = await fetch('/api/users');
// 필터링 및 페이지네이션
const response = await fetch('/api/users?role=LEARNER&status=ACTIVE&page=1&limit=10');
const data = await response.json();
```
#### 쿼리 파라미터
- `role` (선택): `LEARNER` 또는 `ADMIN`
- `status` (선택): `ACTIVE` 또는 `INACTIVE`
- `page` (선택): 페이지 번호 (기본값: 1)
- `limit` (선택): 페이지당 항목 수 (기본값: 10)
#### 성공 응답 (200)
```json
{
"users": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"name": "홍길동",
"role": "LEARNER",
"status": "ACTIVE",
...
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 30,
"totalPages": 3
}
}
```
---
## 2. 교육과정 API
### POST /api/courses - 교육과정 생성
새로운 교육과정을 생성합니다.
#### 요청
```typescript
const response = await fetch('/api/courses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
courseName: '웹 개발 기초',
instructorId: 'instructor-uuid-here', // 필수: 강사(ADMIN 역할)의 ID
createdById: 'admin-uuid-here', // 선택사항: 등록자 ID (기본값: instructorId)
}),
});
const data = await response.json();
```
#### 성공 응답 (201)
```json
{
"message": "교육과정이 성공적으로 생성되었습니다.",
"course": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"courseName": "웹 개발 기초",
"instructorId": "instructor-uuid",
"createdById": "admin-uuid",
"createdAt": "2024-11-21T00:00:00.000Z",
"instructor": {
"id": "instructor-uuid",
"name": "최예준",
"email": "instructor@example.com"
},
"createdBy": {
"id": "admin-uuid",
"name": "관리자"
}
}
}
```
#### 에러 응답
**400 Bad Request** - 필수 필드 누락
```json
{
"error": "교육과정명과 강사 ID는 필수입니다."
}
```
**404 Not Found** - 강사를 찾을 수 없음
```json
{
"error": "강사를 찾을 수 없습니다."
}
```
### GET /api/courses - 교육과정 목록 조회
교육과정 목록을 조회합니다.
#### 요청
```typescript
// 전체 교육과정 조회
const response = await fetch('/api/courses');
// 특정 강사의 교육과정 조회
const response = await fetch('/api/courses?instructorId=instructor-uuid&page=1&limit=10');
const data = await response.json();
```
#### 쿼리 파라미터
- `instructorId` (선택): 강사 ID로 필터링
- `page` (선택): 페이지 번호 (기본값: 1)
- `limit` (선택): 페이지당 항목 수 (기본값: 10)
---
## 3. 강좌 API
### POST /api/lessons - 강좌 생성
새로운 강좌를 생성합니다.
#### 요청
```typescript
const response = await fetch('/api/lessons', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
courseId: 'course-uuid-here', // 필수: 교육과정 ID
lessonName: 'HTML 기초', // 필수: 강좌명
learningGoal: 'HTML의 기본 문법을 이해하고 활용할 수 있다.', // 선택사항: 학습 목표
createdById: 'admin-uuid-here', // 선택사항: 등록자 ID
}),
});
const data = await response.json();
```
#### 성공 응답 (201)
```json
{
"message": "강좌가 성공적으로 생성되었습니다.",
"lesson": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"courseId": "course-uuid",
"lessonName": "HTML 기초",
"learningGoal": "HTML의 기본 문법을 이해하고 활용할 수 있다.",
"createdAt": "2024-11-21T00:00:00.000Z",
"course": {
"id": "course-uuid",
"courseName": "웹 개발 기초"
},
"createdBy": {
"id": "admin-uuid",
"name": "관리자"
},
"_count": {
"videos": 0,
"vrContents": 0,
"questions": 0
}
}
}
```
### GET /api/lessons - 강좌 목록 조회
강좌 목록을 조회합니다.
#### 요청
```typescript
// 전체 강좌 조회
const response = await fetch('/api/lessons');
// 특정 교육과정의 강좌 조회
const response = await fetch('/api/lessons?courseId=course-uuid&page=1&limit=10');
const data = await response.json();
```
---
## 4. 공지사항 API
### POST /api/notices - 공지사항 생성
새로운 공지사항을 생성합니다.
#### 요청
```typescript
const response = await fetch('/api/notices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: '공지사항 제목',
content: '공지사항 내용입니다.\n여러 줄로 작성할 수 있습니다.',
writerId: 'admin-uuid-here', // 필수: 작성자 ID
hasAttachment: false, // 선택사항: 첨부파일 여부 (기본값: false)
}),
});
const data = await response.json();
```
#### 성공 응답 (201)
```json
{
"message": "공지사항이 성공적으로 생성되었습니다.",
"notice": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "공지사항 제목",
"content": "공지사항 내용입니다.\n여러 줄로 작성할 수 있습니다.",
"writerId": "admin-uuid",
"views": 0,
"hasAttachment": false,
"date": "2024-11-21T00:00:00.000Z",
"writer": {
"id": "admin-uuid",
"name": "관리자",
"email": "admin@example.com"
}
}
}
```
### GET /api/notices - 공지사항 목록 조회
공지사항 목록을 조회합니다.
#### 요청
```typescript
// 전체 공지사항 조회
const response = await fetch('/api/notices');
// 특정 작성자의 공지사항 조회
const response = await fetch('/api/notices?writerId=admin-uuid&page=1&limit=10');
const data = await response.json();
```
---
## 에러 처리
모든 API는 일관된 에러 응답 형식을 사용합니다:
```typescript
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
});
if (!response.ok) {
const error = await response.json();
console.error('에러:', error.error);
// 에러 처리 로직
return;
}
const data = await response.json();
console.log('성공:', data);
} catch (error) {
console.error('네트워크 오류:', error);
}
```
### HTTP 상태 코드
- `200` - 성공 (GET 요청)
- `201` - 생성 성공 (POST 요청)
- `400` - 잘못된 요청 (필수 필드 누락 등)
- `404` - 리소스를 찾을 수 없음
- `409` - 충돌 (중복 데이터 등)
- `500` - 서버 오류
---
## 실전 예제
### React 컴포넌트에서 사용하기
```typescript
'use client';
import { useState } from 'react';
export default function CreateUserForm() {
const [formData, setFormData] = useState({
email: '',
password: '',
name: '',
role: 'LEARNER',
});
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setMessage('');
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
const data = await response.json();
if (!response.ok) {
setMessage(`오류: ${data.error}`);
return;
}
setMessage('사용자가 성공적으로 생성되었습니다!');
// 폼 초기화
setFormData({ email: '', password: '', name: '', role: 'LEARNER' });
} catch (error) {
setMessage('네트워크 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="이메일"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
<input
type="password"
placeholder="비밀번호"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
/>
<input
type="text"
placeholder="이름"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
>
<option value="LEARNER">학습자</option>
<option value="ADMIN">관리자</option>
</select>
<button type="submit" disabled={loading}>
{loading ? '생성 중...' : '사용자 생성'}
</button>
{message && <p>{message}</p>}
</form>
);
}
```
### Server Component에서 사용하기
```typescript
// app/admin/users/page.tsx
import { prisma } from '@/lib/prisma';
export default async function UsersPage() {
const users = await prisma.user.findMany({
take: 10,
orderBy: { createdAt: 'desc' },
});
return (
<div>
<h1>사용자 목록</h1>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} ({user.email}) - {user.role}
</li>
))}
</ul>
</div>
);
}
```
### cURL로 테스트하기
```bash
# 사용자 생성
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "test123",
"name": "테스트 사용자",
"role": "LEARNER"
}'
# 사용자 목록 조회
curl http://localhost:3000/api/users?role=LEARNER&page=1&limit=10
# 교육과정 생성
curl -X POST http://localhost:3000/api/courses \
-H "Content-Type: application/json" \
-d '{
"courseName": "웹 개발 기초",
"instructorId": "instructor-uuid-here"
}'
```
---
## 🔒 보안 고려사항
1. **비밀번호 해시화**: 실제 프로덕션에서는 bcrypt 등을 사용하여 비밀번호를 해시화해야 합니다.
2. **인증/인가**: 현재 API는 인증이 없습니다. 프로덕션에서는 JWT 또는 세션 기반 인증을 추가해야 합니다.
3. **입력 검증**: 클라이언트 측 검증 외에도 서버 측 검증이 필요합니다.
4. **CORS 설정**: 필요시 CORS 설정을 추가해야 합니다.
---
## 📚 추가 리소스
- [Next.js API Routes 문서](https://nextjs.org/docs/app/building-your-application/routing/route-handlers)
- [Prisma Client 문서](https://www.prisma.io/docs/concepts/components/prisma-client)

View File

@@ -1,311 +0,0 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('LEARNER', 'ADMIN');
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('ACTIVE', 'INACTIVE');
-- CreateEnum
CREATE TYPE "QuestionType" AS ENUM ('MULTIPLE_CHOICE', 'SHORT_ANSWER', 'ESSAY');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT,
"gender" TEXT,
"birthdate" TIMESTAMP(3),
"role" "UserRole" NOT NULL DEFAULT 'LEARNER',
"status" "UserStatus" NOT NULL DEFAULT 'ACTIVE',
"joinDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "courses" (
"id" TEXT NOT NULL,
"courseName" TEXT NOT NULL,
"instructorId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "courses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lessons" (
"id" TEXT NOT NULL,
"courseId" TEXT NOT NULL,
"lessonName" TEXT NOT NULL,
"learningGoal" TEXT,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "lessons_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lesson_videos" (
"id" TEXT NOT NULL,
"lessonId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL,
"order" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lesson_videos_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lesson_vr_contents" (
"id" TEXT NOT NULL,
"lessonId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL,
"order" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lesson_vr_contents_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lesson_attachments" (
"id" TEXT NOT NULL,
"lessonId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lesson_attachments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "questions" (
"id" TEXT NOT NULL,
"lessonId" TEXT,
"question" TEXT NOT NULL,
"type" "QuestionType" NOT NULL DEFAULT 'MULTIPLE_CHOICE',
"options" JSONB,
"correctAnswer" TEXT NOT NULL,
"explanation" TEXT,
"points" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "questions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notices" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"writerId" TEXT NOT NULL,
"views" INTEGER NOT NULL DEFAULT 0,
"hasAttachment" BOOLEAN NOT NULL DEFAULT false,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notices_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notice_attachments" (
"id" TEXT NOT NULL,
"noticeId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notice_attachments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "resources" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"filePath" TEXT,
"fileName" TEXT,
"fileSize" INTEGER,
"category" TEXT,
"views" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "resources_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "enrollments" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"lessonId" TEXT NOT NULL,
"progress" INTEGER NOT NULL DEFAULT 0,
"completedAt" TIMESTAMP(3),
"enrolledAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "enrollments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "certificates" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"lessonId" TEXT,
"courseId" TEXT,
"verificationKey" TEXT NOT NULL,
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "certificates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "logs" (
"id" TEXT NOT NULL,
"userId" TEXT,
"action" TEXT NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"details" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE INDEX "users_email_idx" ON "users"("email");
-- CreateIndex
CREATE INDEX "users_role_idx" ON "users"("role");
-- CreateIndex
CREATE INDEX "users_status_idx" ON "users"("status");
-- CreateIndex
CREATE INDEX "courses_instructorId_idx" ON "courses"("instructorId");
-- CreateIndex
CREATE INDEX "courses_createdById_idx" ON "courses"("createdById");
-- CreateIndex
CREATE INDEX "courses_createdAt_idx" ON "courses"("createdAt");
-- CreateIndex
CREATE INDEX "lessons_courseId_idx" ON "lessons"("courseId");
-- CreateIndex
CREATE INDEX "lessons_createdById_idx" ON "lessons"("createdById");
-- CreateIndex
CREATE INDEX "lessons_createdAt_idx" ON "lessons"("createdAt");
-- CreateIndex
CREATE INDEX "lesson_videos_lessonId_idx" ON "lesson_videos"("lessonId");
-- CreateIndex
CREATE INDEX "lesson_vr_contents_lessonId_idx" ON "lesson_vr_contents"("lessonId");
-- CreateIndex
CREATE INDEX "lesson_attachments_lessonId_idx" ON "lesson_attachments"("lessonId");
-- CreateIndex
CREATE INDEX "questions_lessonId_idx" ON "questions"("lessonId");
-- CreateIndex
CREATE INDEX "notices_writerId_idx" ON "notices"("writerId");
-- CreateIndex
CREATE INDEX "notices_date_idx" ON "notices"("date");
-- CreateIndex
CREATE UNIQUE INDEX "notice_attachments_noticeId_key" ON "notice_attachments"("noticeId");
-- CreateIndex
CREATE INDEX "resources_category_idx" ON "resources"("category");
-- CreateIndex
CREATE INDEX "resources_createdAt_idx" ON "resources"("createdAt");
-- CreateIndex
CREATE INDEX "enrollments_userId_idx" ON "enrollments"("userId");
-- CreateIndex
CREATE INDEX "enrollments_lessonId_idx" ON "enrollments"("lessonId");
-- CreateIndex
CREATE UNIQUE INDEX "enrollments_userId_lessonId_key" ON "enrollments"("userId", "lessonId");
-- CreateIndex
CREATE UNIQUE INDEX "certificates_verificationKey_key" ON "certificates"("verificationKey");
-- CreateIndex
CREATE INDEX "certificates_userId_idx" ON "certificates"("userId");
-- CreateIndex
CREATE INDEX "certificates_verificationKey_idx" ON "certificates"("verificationKey");
-- CreateIndex
CREATE INDEX "logs_userId_idx" ON "logs"("userId");
-- CreateIndex
CREATE INDEX "logs_action_idx" ON "logs"("action");
-- CreateIndex
CREATE INDEX "logs_createdAt_idx" ON "logs"("createdAt");
-- AddForeignKey
ALTER TABLE "courses" ADD CONSTRAINT "courses_instructorId_fkey" FOREIGN KEY ("instructorId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "courses" ADD CONSTRAINT "courses_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lessons" ADD CONSTRAINT "lessons_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lessons" ADD CONSTRAINT "lessons_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lesson_videos" ADD CONSTRAINT "lesson_videos_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "lessons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lesson_vr_contents" ADD CONSTRAINT "lesson_vr_contents_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "lessons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lesson_attachments" ADD CONSTRAINT "lesson_attachments_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "lessons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "questions" ADD CONSTRAINT "questions_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "lessons"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notices" ADD CONSTRAINT "notices_writerId_fkey" FOREIGN KEY ("writerId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notice_attachments" ADD CONSTRAINT "notice_attachments_noticeId_fkey" FOREIGN KEY ("noticeId") REFERENCES "notices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "enrollments" ADD CONSTRAINT "enrollments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "enrollments" ADD CONSTRAINT "enrollments_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "lessons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "certificates" ADD CONSTRAINT "certificates_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "logs" ADD CONSTRAINT "logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -1,211 +0,0 @@
# API 엔드포인트 문서
이 문서는 데이터베이스에 데이터를 생성하는 API 엔드포인트를 설명합니다.
## 📋 API 목록
### 1. 사용자 API (`/api/users`)
#### POST - 사용자 생성
```bash
POST /api/users
Content-Type: application/json
{
"email": "user@example.com",
"password": "hashed_password",
"name": "홍길동",
"phone": "010-1234-5678",
"gender": "M",
"birthdate": "1990-01-01",
"role": "LEARNER", // 또는 "ADMIN"
"status": "ACTIVE" // 또는 "INACTIVE"
}
```
**응답:**
```json
{
"message": "사용자가 성공적으로 생성되었습니다.",
"user": {
"id": "uuid",
"email": "user@example.com",
"name": "홍길동",
...
}
}
```
#### GET - 사용자 목록 조회
```bash
GET /api/users?role=LEARNER&status=ACTIVE&page=1&limit=10
```
**쿼리 파라미터:**
- `role`: 필터링할 역할 (LEARNER, ADMIN)
- `status`: 필터링할 상태 (ACTIVE, INACTIVE)
- `page`: 페이지 번호 (기본값: 1)
- `limit`: 페이지당 항목 수 (기본값: 10)
---
### 2. 교육과정 API (`/api/courses`)
#### POST - 교육과정 생성
```bash
POST /api/courses
Content-Type: application/json
{
"courseName": "웹 개발 기초",
"instructorId": "instructor_uuid",
"createdById": "admin_uuid" // 선택사항, 기본값: instructorId
}
```
**응답:**
```json
{
"message": "교육과정이 성공적으로 생성되었습니다.",
"course": {
"id": "uuid",
"courseName": "웹 개발 기초",
"instructor": { ... },
"createdBy": { ... }
}
}
```
#### GET - 교육과정 목록 조회
```bash
GET /api/courses?instructorId=uuid&page=1&limit=10
```
---
### 3. 강좌 API (`/api/lessons`)
#### POST - 강좌 생성
```bash
POST /api/lessons
Content-Type: application/json
{
"courseId": "course_uuid",
"lessonName": "HTML 기초",
"learningGoal": "HTML의 기본 문법을 이해하고 활용할 수 있다.",
"createdById": "admin_uuid" // 선택사항
}
```
**응답:**
```json
{
"message": "강좌가 성공적으로 생성되었습니다.",
"lesson": {
"id": "uuid",
"lessonName": "HTML 기초",
"course": { ... },
"createdBy": { ... }
}
}
```
#### GET - 강좌 목록 조회
```bash
GET /api/lessons?courseId=uuid&page=1&limit=10
```
---
### 4. 공지사항 API (`/api/notices`)
#### POST - 공지사항 생성
```bash
POST /api/notices
Content-Type: application/json
{
"title": "공지사항 제목",
"content": "공지사항 내용",
"writerId": "admin_uuid",
"hasAttachment": false // 선택사항
}
```
**응답:**
```json
{
"message": "공지사항이 성공적으로 생성되었습니다.",
"notice": {
"id": "uuid",
"title": "공지사항 제목",
"content": "공지사항 내용",
"writer": { ... }
}
}
```
#### GET - 공지사항 목록 조회
```bash
GET /api/notices?writerId=uuid&page=1&limit=10
```
---
## 🔧 사용 예시
### JavaScript/TypeScript (fetch)
```typescript
// 사용자 생성
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'user@example.com',
password: 'hashed_password',
name: '홍길동',
role: 'LEARNER',
}),
});
const data = await response.json();
console.log(data);
```
### cURL
```bash
# 사용자 생성
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "hashed_password",
"name": "홍길동",
"role": "LEARNER"
}'
```
---
## ⚠️ 주의사항
1. **비밀번호 해시화**: 실제 프로덕션에서는 비밀번호를 해시화하여 저장해야 합니다.
2. **인증/인가**: 현재 API는 인증이 없습니다. 프로덕션에서는 JWT나 세션 기반 인증을 추가해야 합니다.
3. **에러 처리**: 모든 API는 적절한 에러 응답을 반환합니다.
4. **데이터 검증**: 필수 필드 검증이 포함되어 있습니다.
---
## 📝 다음 단계
- [ ] 인증 미들웨어 추가
- [ ] 비밀번호 해시화 로직 추가
- [ ] 파일 업로드 API 추가 (공지사항 첨부파일 등)
- [ ] 수정/삭제 API 추가
- [ ] 상세 조회 API 추가

View File

@@ -1,126 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
// 교육과정 생성
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { courseName, instructorId, createdById } = body;
// 필수 필드 검증
if (!courseName || !instructorId) {
return NextResponse.json(
{ error: '교육과정명과 강사 ID는 필수입니다.' },
{ status: 400 }
);
}
// 강사 존재 확인
const instructor = await prisma.user.findUnique({
where: { id: instructorId },
});
if (!instructor) {
return NextResponse.json(
{ error: '강사를 찾을 수 없습니다.' },
{ status: 404 }
);
}
// 교육과정 생성
const course = await prisma.course.create({
data: {
courseName,
instructorId,
createdById: createdById || instructorId,
},
include: {
instructor: {
select: {
id: true,
name: true,
email: true,
},
},
createdBy: {
select: {
id: true,
name: true,
},
},
},
});
return NextResponse.json(
{ message: '교육과정이 성공적으로 생성되었습니다.', course },
{ status: 201 }
);
} catch (error) {
console.error('교육과정 생성 오류:', error);
return NextResponse.json(
{ error: '교육과정 생성 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}
// 교육과정 목록 조회
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const instructorId = searchParams.get('instructorId');
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const skip = (page - 1) * limit;
const where: any = {};
if (instructorId) where.instructorId = instructorId;
const [courses, total] = await Promise.all([
prisma.course.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
instructor: {
select: {
id: true,
name: true,
email: true,
},
},
createdBy: {
select: {
id: true,
name: true,
},
},
_count: {
select: {
lessons: true,
},
},
},
}),
prisma.course.count({ where }),
]);
return NextResponse.json({
courses,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error('교육과정 조회 오류:', error);
return NextResponse.json(
{ error: '교육과정 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@@ -1,272 +0,0 @@
/**
* API 사용 예제 모음
*
* 이 파일은 API를 사용하는 다양한 예제를 제공합니다.
* 실제 프로젝트에서 참고하여 사용하세요.
*/
// ============================================
// 1. 사용자 API 예제
// ============================================
/**
* 사용자 생성 예제
*/
export async function createUserExample() {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'newuser@example.com',
password: 'hashed_password_here', // 실제로는 bcrypt로 해시화
name: '홍길동',
phone: '010-1234-5678',
gender: 'M',
birthdate: '1990-01-01',
role: 'LEARNER',
status: 'ACTIVE',
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
const data = await response.json();
return data.user;
}
/**
* 사용자 목록 조회 예제 (필터링)
*/
export async function getUsersExample() {
const params = new URLSearchParams({
role: 'LEARNER',
status: 'ACTIVE',
page: '1',
limit: '10',
});
const response = await fetch(`/api/users?${params.toString()}`);
const data = await response.json();
return data;
}
// ============================================
// 2. 교육과정 API 예제
// ============================================
/**
* 교육과정 생성 예제
*/
export async function createCourseExample(instructorId: string) {
const response = await fetch('/api/courses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
courseName: '웹 개발 기초',
instructorId: instructorId,
createdById: instructorId, // 선택사항
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
const data = await response.json();
return data.course;
}
/**
* 교육과정 목록 조회 예제
*/
export async function getCoursesExample(instructorId?: string) {
const params = new URLSearchParams();
if (instructorId) {
params.append('instructorId', instructorId);
}
params.append('page', '1');
params.append('limit', '10');
const response = await fetch(`/api/courses?${params.toString()}`);
const data = await response.json();
return data;
}
// ============================================
// 3. 강좌 API 예제
// ============================================
/**
* 강좌 생성 예제
*/
export async function createLessonExample(courseId: string) {
const response = await fetch('/api/lessons', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
courseId: courseId,
lessonName: 'HTML 기초',
learningGoal: 'HTML의 기본 문법을 이해하고 활용할 수 있다.',
createdById: undefined, // 선택사항
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
const data = await response.json();
return data.lesson;
}
/**
* 강좌 목록 조회 예제
*/
export async function getLessonsExample(courseId?: string) {
const params = new URLSearchParams();
if (courseId) {
params.append('courseId', courseId);
}
params.append('page', '1');
params.append('limit', '10');
const response = await fetch(`/api/lessons?${params.toString()}`);
const data = await response.json();
return data;
}
// ============================================
// 4. 공지사항 API 예제
// ============================================
/**
* 공지사항 생성 예제
*/
export async function createNoticeExample(writerId: string) {
const response = await fetch('/api/notices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: '공지사항 제목',
content: '공지사항 내용입니다.\n여러 줄로 작성할 수 있습니다.',
writerId: writerId,
hasAttachment: false,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
const data = await response.json();
return data.notice;
}
/**
* 공지사항 목록 조회 예제
*/
export async function getNoticesExample(writerId?: string) {
const params = new URLSearchParams();
if (writerId) {
params.append('writerId', writerId);
}
params.append('page', '1');
params.append('limit', '10');
const response = await fetch(`/api/notices?${params.toString()}`);
const data = await response.json();
return data;
}
// ============================================
// 5. 통합 예제 - 전체 워크플로우
// ============================================
/**
* 전체 워크플로우 예제
* 1. 관리자 사용자 생성
* 2. 교육과정 생성
* 3. 강좌 생성
* 4. 공지사항 생성
*/
export async function completeWorkflowExample() {
try {
// 1. 관리자 사용자 생성
const adminUser = await createUserExample();
console.log('관리자 생성:', adminUser);
// 2. 교육과정 생성
const course = await createCourseExample(adminUser.id);
console.log('교육과정 생성:', course);
// 3. 강좌 생성
const lesson = await createLessonExample(course.id);
console.log('강좌 생성:', lesson);
// 4. 공지사항 생성
const notice = await createNoticeExample(adminUser.id);
console.log('공지사항 생성:', notice);
return {
admin: adminUser,
course,
lesson,
notice,
};
} catch (error) {
console.error('워크플로우 실행 중 오류:', error);
throw error;
}
}
// ============================================
// 6. 에러 처리 예제
// ============================================
/**
* 에러 처리를 포함한 안전한 API 호출 예제
*/
export async function safeApiCall<T>(
apiCall: () => Promise<Response>
): Promise<{ data?: T; error?: string }> {
try {
const response = await apiCall();
if (!response.ok) {
const errorData = await response.json();
return { error: errorData.error || '알 수 없는 오류가 발생했습니다.' };
}
const data = await response.json();
return { data: data as T };
} catch (error) {
return {
error: error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.',
};
}
}
// 사용 예시:
// const result = await safeApiCall(() =>
// fetch('/api/users', { method: 'POST', ... })
// );
// if (result.error) {
// console.error(result.error);
// } else {
// console.log(result.data);
// }

View File

@@ -1,127 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
// 강좌 생성
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { courseId, lessonName, learningGoal, createdById } = body;
// 필수 필드 검증
if (!courseId || !lessonName) {
return NextResponse.json(
{ error: '교육과정 ID와 강좌명은 필수입니다.' },
{ status: 400 }
);
}
// 교육과정 존재 확인
const course = await prisma.course.findUnique({
where: { id: courseId },
});
if (!course) {
return NextResponse.json(
{ error: '교육과정을 찾을 수 없습니다.' },
{ status: 404 }
);
}
// 강좌 생성
const lesson = await prisma.lesson.create({
data: {
courseId,
lessonName,
learningGoal: learningGoal || null,
createdById: createdById || course.createdById,
},
include: {
course: {
select: {
id: true,
courseName: true,
},
},
createdBy: {
select: {
id: true,
name: true,
},
},
},
});
return NextResponse.json(
{ message: '강좌가 성공적으로 생성되었습니다.', lesson },
{ status: 201 }
);
} catch (error) {
console.error('강좌 생성 오류:', error);
return NextResponse.json(
{ error: '강좌 생성 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}
// 강좌 목록 조회
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const courseId = searchParams.get('courseId');
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const skip = (page - 1) * limit;
const where: any = {};
if (courseId) where.courseId = courseId;
const [lessons, total] = await Promise.all([
prisma.lesson.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
course: {
select: {
id: true,
courseName: true,
},
},
createdBy: {
select: {
id: true,
name: true,
},
},
_count: {
select: {
videos: true,
vrContents: true,
questions: true,
},
},
},
}),
prisma.lesson.count({ where }),
]);
return NextResponse.json({
lessons,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error('강좌 조회 오류:', error);
return NextResponse.json(
{ error: '강좌 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@@ -1,109 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
// 공지사항 생성
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { title, content, writerId, hasAttachment } = body;
// 필수 필드 검증
if (!title || !content || !writerId) {
return NextResponse.json(
{ error: '제목, 내용, 작성자 ID는 필수입니다.' },
{ status: 400 }
);
}
// 작성자 존재 확인
const writer = await prisma.user.findUnique({
where: { id: writerId },
});
if (!writer) {
return NextResponse.json(
{ error: '작성자를 찾을 수 없습니다.' },
{ status: 404 }
);
}
// 공지사항 생성
const notice = await prisma.notice.create({
data: {
title,
content,
writerId,
hasAttachment: hasAttachment || false,
},
include: {
writer: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
return NextResponse.json(
{ message: '공지사항이 성공적으로 생성되었습니다.', notice },
{ status: 201 }
);
} catch (error) {
console.error('공지사항 생성 오류:', error);
return NextResponse.json(
{ error: '공지사항 생성 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}
// 공지사항 목록 조회
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const writerId = searchParams.get('writerId');
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const skip = (page - 1) * limit;
const where: any = {};
if (writerId) where.writerId = writerId;
const [notices, total] = await Promise.all([
prisma.notice.findMany({
where,
skip,
take: limit,
orderBy: { date: 'desc' },
include: {
writer: {
select: {
id: true,
name: true,
},
},
},
}),
prisma.notice.count({ where }),
]);
return NextResponse.json({
notices,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error('공지사항 조회 오류:', error);
return NextResponse.json(
{ error: '공지사항 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@@ -1,115 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { UserRole, UserStatus } from '@prisma/client';
// 사용자 생성
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password, name, phone, gender, birthdate, role, status } = body;
// 필수 필드 검증
if (!email || !password || !name) {
return NextResponse.json(
{ error: '이메일, 비밀번호, 이름은 필수입니다.' },
{ status: 400 }
);
}
// 이메일 중복 확인
const existingUser = await prisma.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: '이미 존재하는 이메일입니다.' },
{ status: 409 }
);
}
// 사용자 생성
const user = await prisma.user.create({
data: {
email,
password, // 실제로는 해시화된 비밀번호를 저장해야 합니다
name,
phone: phone || null,
gender: gender || null,
birthdate: birthdate ? new Date(birthdate) : null,
role: role || UserRole.LEARNER,
status: status || UserStatus.ACTIVE,
},
});
// 비밀번호 제외하고 반환
const { password: _, ...userWithoutPassword } = user;
return NextResponse.json(
{ message: '사용자가 성공적으로 생성되었습니다.', user: userWithoutPassword },
{ status: 201 }
);
} catch (error) {
console.error('사용자 생성 오류:', error);
return NextResponse.json(
{ error: '사용자 생성 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}
// 사용자 목록 조회
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const role = searchParams.get('role');
const status = searchParams.get('status');
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const skip = (page - 1) * limit;
const where: any = {};
if (role) where.role = role;
if (status) where.status = status;
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
email: true,
name: true,
phone: true,
gender: true,
birthdate: true,
role: true,
status: true,
joinDate: true,
createdAt: true,
updatedAt: true,
},
}),
prisma.user.count({ where }),
]);
return NextResponse.json({
users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error('사용자 조회 오류:', error);
return NextResponse.json(
{ error: '사용자 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@@ -27,12 +27,36 @@ export default function FindIdPage() {
return Object.keys(next).length === 0;
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!validateAll()) return;
const mockUserId = `${name.trim()}@example.com`;
setFoundUserId(mockUserId);
setIsDoneOpen(true);
try {
const response = await fetch('https://hrdi.coconutmeet.net/auth/find-id', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name,
phone: phone,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('아이디 찾기 실패:', errorData.error || response.statusText);
setIsFailedOpen(true);
return;
}
const data = await response.json();
setFoundUserId(data.email);
setIsDoneOpen(true);
} catch (error) {
console.error('아이디 찾기 오류:', error);
setIsFailedOpen(true);
}
}
return (

View File

@@ -41,3 +41,11 @@ body {
background: var(--background);
color: var(--foreground);
}
button {
cursor: pointer;
}
button:hover {
cursor: pointer;
}

View File

@@ -20,13 +20,57 @@ export default function LoginPage() {
const [idError, setIdError] = useState("");
const [passwordError, setPasswordError] = useState("");
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
// 실제 로그인 API 연동 전까지는 실패 모달을 노출합니다.
// API 연동 시 결과에 따라 성공/실패 분기에서 setIsLoginErrorOpen(true) 호출로 교체하세요.
// if (userId.trim().length > 0 && password.trim().length > 0) {
// setIsLoginErrorOpen(true);
// }
if (userId.trim().length === 0 || password.trim().length === 0) {
return;
}
try {
const response = await fetch("https://hrdi.coconutmeet.net/auth/login", {
method: "POST",
headers: {"Content-Type": "application/json",},
body: JSON.stringify({
email: userId,
password: password
})
});
if (!response.ok) {
let errorMessage = `로그인 실패 (${response.status})`;
try {
const errorData = await response.json();
if (errorData.error) {
errorMessage = errorData.error;
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (response.statusText) {
errorMessage = `${response.statusText} (${response.status})`;
}
} catch (parseError) {
if (response.statusText) {
errorMessage = `${response.statusText} (${response.status})`;
}
}
console.error("로그인 실패:", errorMessage);
setIsLoginErrorOpen(true);
return;
}
const data = await response.json();
console.log("로그인 성공:", data);
// 로그인 성공 시 처리 (예: 토큰 저장, 리다이렉트 등)
// TODO: 성공 시 처리 로직 추가 (예: localStorage에 토큰 저장, 메인 페이지로 이동 등)
// if (data.token) {
// localStorage.setItem('token', data.token);
// window.location.href = '/menu';
// }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "네트워크 오류가 발생했습니다.";
console.error("로그인 오류:", errorMessage);
setIsLoginErrorOpen(true);
}
}
return (
@@ -106,12 +150,12 @@ export default function LoginPage() {
onBlur={() => setIsPasswordFocused(false)}
placeholder="비밀번호"
className="
h-[40px] px-[12px] py-[7px] rounded-[8px] w-full border border-neutral-40
focus:outline-none focus:ring-0 focus:ring-offset-0 focus:shadow-none
focus:appearance-none focus:border-neutral-700
text-[18px] text-neutral-700 font-normal leading-[150%] placeholder:text-input-placeholder-text
pr-[40px]
"
h-[40px] px-[12px] py-[7px] rounded-[8px] w-full border border-neutral-40
focus:outline-none focus:ring-0 focus:ring-offset-0 focus:shadow-none
focus:appearance-none focus:border-neutral-700
text-[18px] text-neutral-700 font-normal leading-[150%] placeholder:text-input-placeholder-text
pr-[40px]
"
/>
{password.trim().length > 0 && isPasswordFocused && (
<button

View File

@@ -133,6 +133,10 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
}
);
if (!response.ok) {
if (response.status === 409) {
setErrors((prev) => ({ ...prev, email: "이메일이 중복되었습니다." }));
return;
}
console.error("이메일 인증번호 전송 실패:", response.statusText);
alert("인증번호 전송실패");
return;
@@ -141,6 +145,12 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
setEmailCodeSent(true);
setEmailCode("");
setEmailCodeVerified(false);
// 성공 시 이메일 에러 제거
setErrors((prev) => {
const next = { ...prev };
if (next.email) delete next.email;
return next;
});
} catch (error) {
console.error("이메일 인증번호 전송 오류:", error);
alert("인증번호 전송실패");
@@ -275,7 +285,17 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onChange={(e) => {
setEmail(e.target.value);
// 이메일 변경 시 중복 오류 제거
if (errors.email === "이메일이 중복되었습니다.") {
setErrors((prev) => {
const next = { ...prev };
delete next.email;
return next;
});
}
}}
onFocus={() => setFocused((p) => ({ ...p, email: true }))}
onBlur={() => setFocused((p) => ({ ...p, email: false }))}
placeholder="이메일을 입력해 주세요."
@@ -296,10 +316,10 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
<button
type="button"
disabled={!isEmailValid || emailCodeVerified}
className={`h-[40px] px-[12px] rounded-[8px] text-[16px] font-semibold ${isEmailValid && !emailCodeVerified ? "bg-inactive-button text-white" : "bg-gray-50 text-input-placeholder-text"}`}
className={`h-[40px] w-[130px] px-[12px] rounded-[8px] text-[16px] font-semibold ${isEmailValid && !emailCodeVerified ? "bg-[#F1F3F5] text-neutral-700" : "bg-gray-50 text-input-placeholder-text"}`}
onClick={sendEmailCode}
>
{emailCodeSent && !emailCodeVerified ? "인증번호 재전송" : "인증번호 전송"}
</button>
</div>
{errors.email && <p className="text-error text-[13px] leading-tight">{errors.email}</p>}
@@ -337,7 +357,7 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
<button
type="button"
disabled={emailCodeVerified}
className={`h-[40px] px-[12px] rounded-[8px] text-[16px] font-semibold ${!emailCodeVerified ? "bg-active-button text-white" : "bg-gray-50 text-input-placeholder-text"}`}
className={`h-[40px] w-[130px] px-[12px] rounded-[8px] text-[16px] font-semibold ${!emailCodeVerified ? "bg-[#F1F3F5] text-neutral-700" : "bg-gray-50 text-input-placeholder-text"}`}
onClick={verifyEmailCode}
>
{emailCodeVerified ? "인증완료" : "인증하기"}
@@ -429,8 +449,8 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
onChange={() => setGender("MALE")}
className="sr-only"
/>
<span className={`inline-block rounded-full size-[18px] border ${gender === "MALE" ? "border-active-button" : "border-[#8c95a1]"}`}>
{gender === "MALE" && <span className="block size-[9px] rounded-full bg-active-button m-[4.5px]" />}
<span className={`flex items-center justify-center rounded-full size-[18px] border ${gender === "MALE" ? "border-active-button" : "border-[#8c95a1]"}`}>
{gender === "MALE" && <span className="block size-[9px] rounded-full bg-active-button" />}
</span>
</label>
@@ -443,8 +463,8 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
onChange={() => setGender("FEMALE")}
className="sr-only"
/>
<span className={`inline-block rounded-full size-[18px] border ${gender === "FEMALE" ? "border-active-button" : "border-[#8c95a1]"}`}>
{gender === "FEMALE" && <span className="block size-[9px] rounded-full bg-active-button m-[4.5px]" />}
<span className={`flex items-center justify-center rounded-full size-[18px] border ${gender === "FEMALE" ? "border-active-button" : "border-[#8c95a1]"}`}>
{gender === "FEMALE" && <span className="block size-[9px] rounded-full bg-active-button" />}
</span>
</label>

222
src/lib/email-templates.ts Normal file
View File

@@ -0,0 +1,222 @@
/**
* 이메일 템플릿 유틸리티
* Figma 디자인에 맞춘 이메일 템플릿을 생성합니다.
*/
/**
* 이메일 인증번호 템플릿 생성
* @param verificationCode - 인증번호 (6자리)
* @param baseUrl - 이미지 베이스 URL (선택사항, 기본값: '')
* @returns HTML 형식의 이메일 템플릿
*/
export function generateVerificationEmailTemplate(
verificationCode: string,
baseUrl: string = ''
): string {
// 이미지 URL 생성 (baseUrl이 제공되면 사용, 없으면 상대 경로)
const getImageUrl = (imagePath: string) => {
if (baseUrl) {
return `${baseUrl}${imagePath}`;
}
return imagePath;
};
return `
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>[XR LMS] 이메일 인증 번호</title>
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
background-color: #ffffff;
color: #333c47;
line-height: 1.5;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-top: 4px solid #384fbf;
}
.email-content {
padding: 80px;
display: flex;
flex-direction: column;
gap: 60px;
}
.email-title-wrapper {
width: 100%;
display: block;
}
.email-title-image {
width: 100%;
height: auto;
display: block;
font-family: 'Pretendard', sans-serif;
font-weight: 700;
font-size: 24px;
line-height: 1.5;
color: #333c47;
}
.verification-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.verification-code {
font-family: 'Pretendard', sans-serif;
font-weight: 700;
font-size: 36px;
line-height: 1.5;
color: #384fbf;
letter-spacing: 0.05em;
}
.verification-instructions-wrapper {
width: 100%;
display: block;
}
.verification-instructions-image {
width: 100%;
height: auto;
display: block;
font-family: 'Pretendard', sans-serif;
font-weight: 500;
font-size: 18px;
line-height: 1.5;
color: #6c7682;
}
.email-footer-wrapper {
width: 100%;
display: block;
}
.email-footer-image {
width: 100%;
height: auto;
display: block;
font-family: 'Pretendard', 'Noto Sans', sans-serif;
font-weight: 400;
font-size: 14px;
line-height: 1.5;
color: #8c95a1;
}
/* 이미지가 로드되지 않을 때를 대비한 fallback 텍스트 숨김 */
.email-title-fallback,
.verification-instructions-fallback,
.email-footer-fallback {
display: none;
}
@media only screen and (max-width: 600px) {
.email-content {
padding: 40px 20px;
gap: 40px;
}
.email-title-image {
font-size: 20px;
}
.verification-code {
font-size: 32px;
}
.verification-instructions-image {
font-size: 16px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="email-content">
<div class="email-title-wrapper">
<img
src="${getImageUrl('/imgs/email-title.png')}"
alt="[XR LMS] 이메일 인증 번호"
class="email-title-image"
style="display: block; width: 100%; height: auto;"
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';"
/>
<div class="email-title-fallback" style="font-family: 'Pretendard', sans-serif; font-weight: 700; font-size: 24px; line-height: 1.5; color: #333c47;">
[XR LMS] 이메일 인증 번호
</div>
</div>
<div class="verification-section">
<div class="verification-code">
${verificationCode}
</div>
<div class="verification-instructions-wrapper">
<img
src="${getImageUrl('/imgs/email-instructions.png')}"
alt="위 번호를 인증번호 입력창에 입력해 주세요. 인증번호 입력 후 해당 화면 내 [인증번호 확인]버튼을 클릭해 주세요."
class="verification-instructions-image"
style="display: block; width: 100%; height: auto;"
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';"
/>
<div class="verification-instructions-fallback" style="font-family: 'Pretendard', sans-serif; font-weight: 500; font-size: 18px; line-height: 1.5; color: #6c7682;">
위 번호를 인증번호 입력창에 입력해 주세요.<br>
인증번호 입력 후 해당 화면 내 [인증번호 확인]버튼을 클릭해 주세요.
</div>
</div>
</div>
<div class="email-footer-wrapper">
<img
src="${getImageUrl('/imgs/email-footer.png')}"
alt="Copyright ⓒ 2025 XL LMS. All rights reserved"
class="email-footer-image"
style="display: block; width: 100%; height: auto;"
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';"
/>
<div class="email-footer-fallback" style="font-family: 'Pretendard', 'Noto Sans', sans-serif; font-weight: 400; font-size: 14px; line-height: 1.5; color: #8c95a1;">
Copyright ⓒ 2025 XL LMS. All rights reserved
</div>
</div>
</div>
</div>
</body>
</html>
`.trim();
}
/**
* 이메일 인증번호 템플릿 (텍스트 버전)
* @param verificationCode - 인증번호 (6자리)
* @returns 텍스트 형식의 이메일 템플릿
*/
export function generateVerificationEmailTextTemplate(verificationCode: string): string {
return `
[XR LMS] 이메일 인증 번호
인증번호: ${verificationCode}
위 번호를 인증번호 입력창에 입력해 주세요.
인증번호 입력 후 해당 화면 내 [인증번호 확인]버튼을 클릭해 주세요.
Copyright ⓒ 2025 XL LMS. All rights reserved
`.trim();
}