Compare commits
5 Commits
071a686ffa
...
e74061057d
| Author | SHA1 | Date | |
|---|---|---|---|
| e74061057d | |||
| 12413795f4 | |||
| 7e122453bb | |||
| eaec13e386 | |||
| 74eee0a3c0 |
565
API_USAGE.md
565
API_USAGE.md
@@ -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)
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
@@ -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"
|
|
||||||
@@ -15,11 +15,55 @@ const NAV_ITEMS = [
|
|||||||
export default function NavBar() {
|
export default function NavBar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||||
|
const [userName, setUserName] = useState<string>('');
|
||||||
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const userButtonRef = useRef<HTMLButtonElement | null>(null);
|
const userButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const hideCenterNav = /^\/[^/]+\/review$/.test(pathname);
|
const hideCenterNav = /^\/[^/]+\/review$/.test(pathname);
|
||||||
const isAdminPage = pathname.startsWith('/admin');
|
const isAdminPage = pathname.startsWith('/admin');
|
||||||
|
|
||||||
|
// 사용자 정보 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchUserInfo() {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('https://hrdi.coconutmeet.net/auth/me', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
// 토큰이 만료되었거나 유효하지 않은 경우
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (isMounted && data.name) {
|
||||||
|
setUserName(data.name);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사용자 정보 조회 오류:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUserInfo();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isUserMenuOpen) return;
|
if (!isUserMenuOpen) return;
|
||||||
const onDown = (e: MouseEvent) => {
|
const onDown = (e: MouseEvent) => {
|
||||||
@@ -91,7 +135,7 @@ export default function NavBar() {
|
|||||||
aria-expanded={isUserMenuOpen}
|
aria-expanded={isUserMenuOpen}
|
||||||
className="flex items-center gap-1 px-4 py-2 text-[16px] font-semibold text-white cursor-pointer"
|
className="flex items-center gap-1 px-4 py-2 text-[16px] font-semibold text-white cursor-pointer"
|
||||||
>
|
>
|
||||||
김이름
|
{userName || '사용자'}
|
||||||
<ChevronDownSvg
|
<ChevronDownSvg
|
||||||
width={16}
|
width={16}
|
||||||
height={16}
|
height={16}
|
||||||
|
|||||||
@@ -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 추가
|
|
||||||
|
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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);
|
|
||||||
// }
|
|
||||||
|
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -27,12 +27,51 @@ export default function FindIdPage() {
|
|||||||
return Object.keys(next).length === 0;
|
return Object.keys(next).length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!validateAll()) return;
|
if (!validateAll()) return;
|
||||||
const mockUserId = `${name.trim()}@example.com`;
|
|
||||||
setFoundUserId(mockUserId);
|
try {
|
||||||
setIsDoneOpen(true);
|
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) {
|
||||||
|
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);
|
||||||
|
setIsFailedOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setFoundUserId(data.email);
|
||||||
|
setIsDoneOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
||||||
|
console.error('아이디 찾기 오류:', errorMessage);
|
||||||
|
setIsFailedOpen(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -41,3 +41,11 @@ body {
|
|||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import MainLogo from "@/app/svgs/mainlogosvg"
|
import MainLogo from "@/app/svgs/mainlogosvg"
|
||||||
import LoginCheckboxActiveSvg from "@/app/svgs/logincheckboxactivesvg";
|
import LoginCheckboxActiveSvg from "@/app/svgs/logincheckboxactivesvg";
|
||||||
@@ -10,6 +11,7 @@ import LoginErrorModal from "./LoginErrorModal";
|
|||||||
import LoginOption from "@/app/login/LoginOption";
|
import LoginOption from "@/app/login/LoginOption";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [userId, setUserId] = useState("");
|
const [userId, setUserId] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [rememberId, setRememberId] = useState(false);
|
const [rememberId, setRememberId] = useState(false);
|
||||||
@@ -20,13 +22,69 @@ export default function LoginPage() {
|
|||||||
const [idError, setIdError] = useState("");
|
const [idError, setIdError] = useState("");
|
||||||
const [passwordError, setPasswordError] = useState("");
|
const [passwordError, setPasswordError] = useState("");
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// 실제 로그인 API 연동 전까지는 실패 모달을 노출합니다.
|
if (userId.trim().length === 0 || password.trim().length === 0) {
|
||||||
// API 연동 시 결과에 따라 성공/실패 분기에서 setIsLoginErrorOpen(true) 호출로 교체하세요.
|
return;
|
||||||
// if (userId.trim().length > 0 && password.trim().length > 0) {
|
}
|
||||||
// setIsLoginErrorOpen(true);
|
|
||||||
// }
|
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);
|
||||||
|
|
||||||
|
// 로그인 성공 시 토큰 저장 (다양한 필드명 지원)
|
||||||
|
const token = data.token || data.accessToken || data.access_token;
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
// 쿠키에도 토큰 저장 (middleware에서 사용)
|
||||||
|
document.cookie = `token=${token}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`;
|
||||||
|
console.log("토큰 저장 완료");
|
||||||
|
} else {
|
||||||
|
console.warn("토큰이 응답에 없습니다. 응답 데이터:", data);
|
||||||
|
// 토큰이 없어도 로그인은 성공했으므로 진행
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리다이렉트 경로 확인
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
const redirectPath = searchParams.get('redirect') || '/';
|
||||||
|
|
||||||
|
// 메인 페이지로 이동
|
||||||
|
router.push(redirectPath);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "네트워크 오류가 발생했습니다.";
|
||||||
|
console.error("로그인 오류:", errorMessage);
|
||||||
|
setIsLoginErrorOpen(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -106,12 +164,12 @@ export default function LoginPage() {
|
|||||||
onBlur={() => setIsPasswordFocused(false)}
|
onBlur={() => setIsPasswordFocused(false)}
|
||||||
placeholder="비밀번호"
|
placeholder="비밀번호"
|
||||||
className="
|
className="
|
||||||
h-[40px] px-[12px] py-[7px] rounded-[8px] w-full border border-neutral-40
|
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:outline-none focus:ring-0 focus:ring-offset-0 focus:shadow-none
|
||||||
focus:appearance-none focus:border-neutral-700
|
focus:appearance-none focus:border-neutral-700
|
||||||
text-[18px] text-neutral-700 font-normal leading-[150%] placeholder:text-input-placeholder-text
|
text-[18px] text-neutral-700 font-normal leading-[150%] placeholder:text-input-placeholder-text
|
||||||
pr-[40px]
|
pr-[40px]
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
{password.trim().length > 0 && isPasswordFocused && (
|
{password.trim().length > 0 && isPasswordFocused && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import ModalCloseSvg from "../svgs/closexsvg";
|
import ModalCloseSvg from "../svgs/closexsvg";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -9,6 +11,78 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AccountDeleteModal({ open, onClose, onConfirm }: Props) {
|
export default function AccountDeleteModal({ open, onClose, onConfirm }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
alert('로그인이 필요합니다.');
|
||||||
|
setIsLoading(false);
|
||||||
|
onClose();
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('회원 탈퇴 요청 시작, 토큰 존재:', !!token);
|
||||||
|
console.log('토큰 길이:', token?.length);
|
||||||
|
console.log('토큰 시작 부분:', token?.substring(0, 20));
|
||||||
|
|
||||||
|
const response = await fetch('https://hrdi.coconutmeet.net/auth/delete/me', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('회원 탈퇴 응답 상태:', response.status);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `회원 탈퇴 실패 (${response.status})`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
console.error('회원 탈퇴 API 오류 응답:', errorData);
|
||||||
|
if (errorData.error) {
|
||||||
|
errorMessage = errorData.error;
|
||||||
|
} else if (errorData.message) {
|
||||||
|
errorMessage = errorData.message;
|
||||||
|
} else if (errorData.errorMessage) {
|
||||||
|
errorMessage = errorData.errorMessage;
|
||||||
|
} else if (response.statusText) {
|
||||||
|
errorMessage = `${response.statusText} (${response.status})`;
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('응답 파싱 오류:', parseError);
|
||||||
|
if (response.statusText) {
|
||||||
|
errorMessage = `${response.statusText} (${response.status})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error('회원 탈퇴 실패:', errorMessage, '상태 코드:', response.status);
|
||||||
|
alert(errorMessage);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 시 토큰 제거 및 로그인 페이지로 이동
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
onClose();
|
||||||
|
router.push('/login');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
||||||
|
console.error('회원 탈퇴 오류:', errorMessage);
|
||||||
|
alert(errorMessage);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -20,7 +94,7 @@ export default function AccountDeleteModal({ open, onClose, onConfirm }: Props)
|
|||||||
<div className="w-[528px] rounded-[12px] border border-[#dee1e6] bg-white shadow-[0_10px_30px_rgba(0,0,0,0.06)]">
|
<div className="w-[528px] rounded-[12px] border border-[#dee1e6] bg-white shadow-[0_10px_30px_rgba(0,0,0,0.06)]">
|
||||||
{/* header */}
|
{/* header */}
|
||||||
<div className="flex items-center justify-between p-6">
|
<div className="flex items-center justify-between p-6">
|
||||||
<h2 className="text-[20px] font-bold leading-[1.5] text-[#333c47]">회원 탈퇴</h2>
|
<h2 className="text-[20px] font-bold leading-normal text-[#333c47]">회원 탈퇴</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="닫기"
|
aria-label="닫기"
|
||||||
@@ -34,10 +108,10 @@ export default function AccountDeleteModal({ open, onClose, onConfirm }: Props)
|
|||||||
{/* body */}
|
{/* body */}
|
||||||
<div className="px-6">
|
<div className="px-6">
|
||||||
<div className="rounded-[16px] border border-[#dee1e6] bg-gray-50 p-6">
|
<div className="rounded-[16px] border border-[#dee1e6] bg-gray-50 p-6">
|
||||||
<p className="mb-3 text-[15px] font-bold leading-[1.5] text-[#4c5561]">
|
<p className="mb-3 text-[15px] font-bold leading-normal text-basic-text">
|
||||||
회원 탈퇴 시 유의사항을 확인해주세요.
|
회원 탈퇴 시 유의사항을 확인해주세요.
|
||||||
</p>
|
</p>
|
||||||
<div className="text-[15px] leading-[1.5] text-[#4c5561]">
|
<div className="text-[15px] leading-normal text-basic-text">
|
||||||
<p className="mb-0">- 탈퇴 후에도 재가입은 가능합니다.</p>
|
<p className="mb-0">- 탈퇴 후에도 재가입은 가능합니다.</p>
|
||||||
<p className="mb-0">- 수강 및 학습 이력이 모두 삭제되며, 복구가 불가능합니다.</p>
|
<p className="mb-0">- 수강 및 학습 이력이 모두 삭제되며, 복구가 불가능합니다.</p>
|
||||||
<p>- 수강 서비스 이용 권한이 즉시 종료됩니다.</p>
|
<p>- 수강 서비스 이용 권한이 즉시 종료됩니다.</p>
|
||||||
@@ -50,16 +124,17 @@ export default function AccountDeleteModal({ open, onClose, onConfirm }: Props)
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="h-12 w-[136px] rounded-[10px] bg-[#f1f3f5] px-4 text-[16px] font-semibold leading-[1.5] text-[#4c5561] cursor-pointer"
|
className="h-12 w-[136px] rounded-[10px] bg-[#f1f3f5] px-4 text-[16px] font-semibold leading-normal text-basic-text cursor-pointer"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onConfirm}
|
onClick={handleConfirm}
|
||||||
className="h-12 w-[136px] rounded-[10px] bg-red-50 px-4 text-[16px] font-semibold leading-[1.5] text-[#f64c4c] cursor-pointer"
|
disabled={isLoading}
|
||||||
|
className="h-12 w-[136px] rounded-[10px] bg-red-50 px-4 text-[16px] font-semibold leading-normal text-error cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
회원 탈퇴
|
{isLoading ? '처리 중...' : '회원 탈퇴'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import ChangePasswordModal from "../ChangePasswordModal";
|
import ChangePasswordModal from "../ChangePasswordModal";
|
||||||
import PasswordChangeDoneModal from "../PasswordChangeDoneModal";
|
import PasswordChangeDoneModal from "../PasswordChangeDoneModal";
|
||||||
import AccountDeleteModal from "../AccountDeleteModal";
|
import AccountDeleteModal from "../AccountDeleteModal";
|
||||||
@@ -8,11 +9,90 @@ import MenuAccountOption from "@/app/menu/account/MenuAccountOption";
|
|||||||
|
|
||||||
type VerificationState = 'initial' | 'sent' | 'verified' | 'failed' | 'changed';
|
type VerificationState = 'initial' | 'sent' | 'verified' | 'failed' | 'changed';
|
||||||
|
|
||||||
|
type UserInfo = {
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function AccountPage() {
|
export default function AccountPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [verificationState, setVerificationState] = useState<VerificationState>('initial');
|
const [verificationState, setVerificationState] = useState<VerificationState>('initial');
|
||||||
const [doneOpen, setDoneOpen] = useState(false);
|
const [doneOpen, setDoneOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [userInfo, setUserInfo] = useState<UserInfo>({});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// 페이지 로드 시 사용자 정보 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchUserInfo() {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
if (isMounted) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('https://hrdi.coconutmeet.net/auth/me', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
// 토큰이 만료되었거나 유효하지 않은 경우
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
if (isMounted) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let errorMessage = `사용자 정보 조회 실패 (${response.status})`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.error) {
|
||||||
|
errorMessage = errorData.error;
|
||||||
|
} else if (errorData.message) {
|
||||||
|
errorMessage = errorData.message;
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
console.error('사용자 정보 조회 실패:', errorMessage);
|
||||||
|
if (isMounted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (isMounted) {
|
||||||
|
setUserInfo(data);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
||||||
|
console.error('사용자 정보 조회 오류:', errorMessage);
|
||||||
|
if (isMounted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUserInfo();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 개발 옵션에서 'changed'로 전환하면 완료 모달 표시
|
// 개발 옵션에서 'changed'로 전환하면 완료 모달 표시
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -22,7 +102,7 @@ export default function AccountPage() {
|
|||||||
return (
|
return (
|
||||||
<main className="flex w-full flex-col">
|
<main className="flex w-full flex-col">
|
||||||
<div className="flex h-[100px] items-center px-8">
|
<div className="flex h-[100px] items-center px-8">
|
||||||
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">내 정보 수정</h1>
|
<h1 className="text-[24px] font-bold leading-normal text-[#1b2027]">내 정보 수정</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-8 pb-20">
|
<div className="px-8 pb-20">
|
||||||
<div className="rounded-lg border border-[#dee1e6] bg-white p-8">
|
<div className="rounded-lg border border-[#dee1e6] bg-white p-8">
|
||||||
@@ -31,7 +111,9 @@ export default function AccountPage() {
|
|||||||
아이디 (이메일)
|
아이디 (이메일)
|
||||||
</label>
|
</label>
|
||||||
<div className="h-10 rounded-lg border border-[#dee1e6] bg-neutral-50 px-3 py-2">
|
<div className="h-10 rounded-lg border border-[#dee1e6] bg-neutral-50 px-3 py-2">
|
||||||
<span className="text-[16px] leading-[1.5] text-[#333c47]">skyblue@edu.com</span>
|
<span className="text-[16px] leading-normal text-[#333c47]">
|
||||||
|
{isLoading ? '로딩 중...' : (userInfo.email || '이메일 정보 없음')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex flex-col gap-2">
|
<div className="mt-6 flex flex-col gap-2">
|
||||||
@@ -40,7 +122,7 @@ export default function AccountPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-10 flex-1 rounded-lg border border-[#dee1e6] bg-neutral-50 px-3 py-2">
|
<div className="h-10 flex-1 rounded-lg border border-[#dee1e6] bg-neutral-50 px-3 py-2">
|
||||||
<span className="text-[16px] leading-[1.5] text-[#333c47]">●●●●●●●●●●</span>
|
<span className="text-[16px] leading-normal text-[#333c47]">●●●●●●●●●●</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -89,9 +171,47 @@ export default function AccountPage() {
|
|||||||
<AccountDeleteModal
|
<AccountDeleteModal
|
||||||
open={deleteOpen}
|
open={deleteOpen}
|
||||||
onClose={() => setDeleteOpen(false)}
|
onClose={() => setDeleteOpen(false)}
|
||||||
onConfirm={() => {
|
onConfirm={async () => {
|
||||||
// TODO: 탈퇴 API 연동
|
try {
|
||||||
setDeleteOpen(false);
|
const token = localStorage.getItem('token');
|
||||||
|
const response = await fetch('https://hrdi.coconutmeet.net/auth/delete/me', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
alert(errorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 시 토큰 제거 및 로그인 페이지로 이동
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setDeleteOpen(false);
|
||||||
|
router.push('/login');
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
|
||||||
|
console.error('회원 탈퇴 오류:', errorMessage);
|
||||||
|
alert(errorMessage);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import LoginCheckboxActiveSvg from "@/app/svgs/logincheckboxactivesvg";
|
import LoginCheckboxActiveSvg from "@/app/svgs/logincheckboxactivesvg";
|
||||||
import LoginCheckboxInactiveSvg from "@/app/svgs/logincheckboxinactivesvg";
|
import LoginCheckboxInactiveSvg from "@/app/svgs/logincheckboxinactivesvg";
|
||||||
import LoginInputSvg from "@/app/svgs/inputformx";
|
import LoginInputSvg from "@/app/svgs/inputformx";
|
||||||
|
|
||||||
type Gender = "male" | "female" | "";
|
type Gender = "MALE" | "FEMALE" | "";
|
||||||
|
|
||||||
type RegisterFormProps = {
|
type RegisterFormProps = {
|
||||||
onOpenDone: () => void;
|
onOpenDone: () => void;
|
||||||
@@ -73,10 +73,139 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
return Object.keys(nextErrors).length === 0;
|
return Object.keys(nextErrors).length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
// 입력 필드가 유효해지면 해당 필드의 에러를 자동으로 지움
|
||||||
|
useEffect(() => {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (name.trim().length > 0 && prev.name) delete next.name;
|
||||||
|
if (isPhoneValid && prev.phone) delete next.phone;
|
||||||
|
if (isEmailValid && prev.email) delete next.email;
|
||||||
|
if (isPasswordValid && prev.password) delete next.password;
|
||||||
|
if (isPasswordConfirmValid && prev.passwordConfirm) delete next.passwordConfirm;
|
||||||
|
if (gender !== "" && prev.gender) delete next.gender;
|
||||||
|
if (birthdate.trim().length > 0 && prev.birthdate) delete next.birthdate;
|
||||||
|
if (allAgree && prev.agreements) delete next.agreements;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [name, isPhoneValid, isEmailValid, isPasswordValid, isPasswordConfirmValid, gender, birthdate, allAgree]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
console.log("handleSubmit");
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!validateAll()) return;
|
if (!validateAll()) return;
|
||||||
onOpenDone();
|
await RegisterUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyEmailCode() {
|
||||||
|
try{
|
||||||
|
const response = await fetch(
|
||||||
|
"https://hrdi.coconutmeet.net/auth/verify-email/confirm",
|
||||||
|
{
|
||||||
|
method: "POST", headers: {"Content-Type": "application/json",},
|
||||||
|
body: JSON.stringify({email: email,emailCode: emailCode})
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("이메일 인증번호 검증 실패:", response.statusText);
|
||||||
|
onOpenCodeError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 인증 성공 시 상태 업데이트
|
||||||
|
setEmailCodeVerified(true);
|
||||||
|
}
|
||||||
|
catch(error){
|
||||||
|
console.error("이메일 인증번호 검증 오류:", error);
|
||||||
|
onOpenCodeError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function sendEmailCode() {
|
||||||
|
|
||||||
|
if (!isEmailValid) return;
|
||||||
|
// INSERT_YOUR_CODE
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
"https://hrdi.coconutmeet.net/auth/verify-email/send",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json",},
|
||||||
|
body: JSON.stringify({email: email})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 409) {
|
||||||
|
setErrors((prev) => ({ ...prev, email: "이메일이 중복되었습니다." }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("이메일 인증번호 전송 실패:", response.statusText);
|
||||||
|
alert("인증번호 전송실패");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 성공 시에만 상태 업데이트
|
||||||
|
setEmailCodeSent(true);
|
||||||
|
setEmailCode("");
|
||||||
|
setEmailCodeVerified(false);
|
||||||
|
// 성공 시 이메일 에러 제거
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (next.email) delete next.email;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("이메일 인증번호 전송 오류:", error);
|
||||||
|
alert("인증번호 전송실패");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function RegisterUser() {
|
||||||
|
if (!emailCodeVerified) {
|
||||||
|
onOpenCodeError("이메일 인증을 완료해주세요.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://hrdi.coconutmeet.net/auth/signup", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json",},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: email,
|
||||||
|
emailCode: emailCode,
|
||||||
|
password: password,
|
||||||
|
passwordConfirm: passwordConfirm,
|
||||||
|
name: name,
|
||||||
|
phone: phone,
|
||||||
|
gender: gender,
|
||||||
|
birthDate: birthdate
|
||||||
|
})
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
onOpenCodeError(errorMessage);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
onOpenDone();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "네트워크 오류가 발생했습니다.";
|
||||||
|
console.error("회원가입 오류:", errorMessage);
|
||||||
|
onOpenCodeError(errorMessage);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -87,7 +216,6 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
회원가입
|
회원가입
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* 이름 */}
|
{/* 이름 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -157,13 +285,24 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={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 }))}
|
onFocus={() => setFocused((p) => ({ ...p, email: true }))}
|
||||||
onBlur={() => setFocused((p) => ({ ...p, email: false }))}
|
onBlur={() => setFocused((p) => ({ ...p, email: false }))}
|
||||||
placeholder="이메일을 입력해 주세요."
|
placeholder="이메일을 입력해 주세요."
|
||||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
disabled={emailCodeVerified}
|
||||||
|
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px] disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
{email.trim().length > 0 && focused.email && (
|
{email.trim().length > 0 && focused.email && !emailCodeVerified && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={(e) => { e.preventDefault(); setEmail(""); }}
|
onMouseDown={(e) => { e.preventDefault(); setEmail(""); }}
|
||||||
@@ -176,17 +315,11 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!isEmailValid}
|
disabled={!isEmailValid || emailCodeVerified}
|
||||||
className={`h-[40px] px-[12px] rounded-[8px] text-[16px] font-semibold ${isEmailValid ? "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={() => {
|
onClick={sendEmailCode}
|
||||||
if (!isEmailValid) return;
|
|
||||||
alert("인증번호 전송 (가상 동작)");
|
|
||||||
setEmailCodeSent(true);
|
|
||||||
setEmailCode("");
|
|
||||||
setEmailCodeVerified(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
인증번호 전송
|
{emailCodeSent && !emailCodeVerified ? "인증번호 재전송" : "인증번호 전송"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{errors.email && <p className="text-error text-[13px] leading-tight">{errors.email}</p>}
|
{errors.email && <p className="text-error text-[13px] leading-tight">{errors.email}</p>}
|
||||||
@@ -207,7 +340,8 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
onFocus={() => setFocused((p) => ({ ...p, emailCode: true }))}
|
onFocus={() => setFocused((p) => ({ ...p, emailCode: true }))}
|
||||||
onBlur={() => setFocused((p) => ({ ...p, emailCode: false }))}
|
onBlur={() => setFocused((p) => ({ ...p, emailCode: false }))}
|
||||||
placeholder="인증번호 6자리"
|
placeholder="인증번호 6자리"
|
||||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
disabled={emailCodeVerified}
|
||||||
|
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px] disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
{emailCode.trim().length > 0 && focused.emailCode && !emailCodeVerified && (
|
{emailCode.trim().length > 0 && focused.emailCode && !emailCodeVerified && (
|
||||||
<button
|
<button
|
||||||
@@ -223,19 +357,23 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={emailCodeVerified}
|
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={() => {
|
onClick={verifyEmailCode}
|
||||||
// 가상 검증: 6자리면 성공, 아니면 에러 모달
|
|
||||||
if (emailCode.length !== 6) {
|
|
||||||
onOpenCodeError();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setEmailCodeVerified(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{emailCodeVerified ? "인증완료" : "인증하기"}
|
{emailCodeVerified ? "인증완료" : "인증하기"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[13px] leading-[normal] text-[#384fbf]">
|
||||||
|
{emailCodeVerified ? (
|
||||||
|
"인증이 완료됐습니다"
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
인증 확인을 위해 작성한 이메일로 인증번호를 발송했습니다.
|
||||||
|
<br />
|
||||||
|
이메일을 확인해 주세요.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -306,13 +444,13 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="gender"
|
name="gender"
|
||||||
value="male"
|
value="MALE"
|
||||||
checked={gender === "male"}
|
checked={gender === "MALE"}
|
||||||
onChange={() => setGender("male")}
|
onChange={() => setGender("MALE")}
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
/>
|
/>
|
||||||
<span className={`inline-block rounded-full size-[18px] border ${gender === "male" ? "border-active-button" : "border-[#8c95a1]"}`}>
|
<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 m-[4.5px]" />}
|
{gender === "MALE" && <span className="block size-[9px] rounded-full bg-active-button" />}
|
||||||
</span>
|
</span>
|
||||||
남성
|
남성
|
||||||
</label>
|
</label>
|
||||||
@@ -320,13 +458,13 @@ export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFo
|
|||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="gender"
|
name="gender"
|
||||||
value="female"
|
value="FEMALE"
|
||||||
checked={gender === "female"}
|
checked={gender === "FEMALE"}
|
||||||
onChange={() => setGender("female")}
|
onChange={() => setGender("FEMALE")}
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
/>
|
/>
|
||||||
<span className={`inline-block rounded-full size-[18px] border ${gender === "female" ? "border-active-button" : "border-[#8c95a1]"}`}>
|
<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 m-[4.5px]" />}
|
{gender === "FEMALE" && <span className="block size-[9px] rounded-full bg-active-button" />}
|
||||||
</span>
|
</span>
|
||||||
여성
|
여성
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default function RegisterPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="min-h-screen w-full flex flex-col items-center justify-between">
|
<div className="min-h-screen w-full flex flex-col items-center justify-between">
|
||||||
|
|
||||||
<RegisterForm
|
<RegisterForm
|
||||||
onOpenDone={() => setDoneOpen(true)}
|
onOpenDone={() => setDoneOpen(true)}
|
||||||
onOpenCodeError={(msg) => {
|
onOpenCodeError={(msg) => {
|
||||||
@@ -21,6 +22,7 @@ export default function RegisterPage() {
|
|||||||
setCodeErrorOpen(true);
|
setCodeErrorOpen(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RegisterOption
|
<RegisterOption
|
||||||
doneOpen={doneOpen}
|
doneOpen={doneOpen}
|
||||||
setDoneOpen={setDoneOpen}
|
setDoneOpen={setDoneOpen}
|
||||||
|
|||||||
222
src/lib/email-templates.ts
Normal file
222
src/lib/email-templates.ts
Normal 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();
|
||||||
|
}
|
||||||
|
|
||||||
57
src/middleware.ts
Normal file
57
src/middleware.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// 공개 경로 (인증 불필요)
|
||||||
|
const publicPaths = [
|
||||||
|
'/login',
|
||||||
|
'/register',
|
||||||
|
'/find-id',
|
||||||
|
'/reset-password',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 정적 파일 및 API 경로는 제외
|
||||||
|
if (
|
||||||
|
pathname.startsWith('/_next') ||
|
||||||
|
pathname.startsWith('/api') ||
|
||||||
|
pathname.startsWith('/fonts') ||
|
||||||
|
pathname.startsWith('/imgs') ||
|
||||||
|
pathname.match(/\.(ico|png|jpg|jpeg|svg|woff|woff2)$/)
|
||||||
|
) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공개 경로는 통과
|
||||||
|
if (publicPaths.some(path => pathname.startsWith(path))) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 토큰 확인 (쿠키에서)
|
||||||
|
const token = request.cookies.get('token')?.value;
|
||||||
|
|
||||||
|
// 토큰이 없으면 로그인 페이지로 리다이렉트
|
||||||
|
if (!token) {
|
||||||
|
const loginUrl = new URL('/login', request.url);
|
||||||
|
// 원래 요청한 경로를 쿼리 파라미터로 저장 (로그인 후 돌아갈 수 있도록)
|
||||||
|
loginUrl.searchParams.set('redirect', pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except for the ones starting with:
|
||||||
|
* - api (API routes)
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico (favicon file)
|
||||||
|
*/
|
||||||
|
'/((?!api|_next/static|_next/image|favicon.ico).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
Reference in New Issue
Block a user