diff --git a/.gitignore b/.gitignore index 5ef6a52..f390d12 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/src/generated/prisma diff --git a/API_USAGE.md b/API_USAGE.md new file mode 100644 index 0000000..77a73a6 --- /dev/null +++ b/API_USAGE.md @@ -0,0 +1,565 @@ +# 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 ( +
+ setFormData({ ...formData, email: e.target.value })} + required + /> + setFormData({ ...formData, password: e.target.value })} + required + /> + setFormData({ ...formData, name: e.target.value })} + required + /> + + + {message &&

{message}

} +
+ ); +} +``` + +### 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 ( +
+

사용자 목록

+ +
+ ); +} +``` + +### 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) + diff --git a/package-lock.json b/package-lock.json index 91823a6..66c4564 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,10 @@ "name": "xrlms", "version": "0.1.0", "dependencies": { + "@prisma/client": "^6.19.0", + "dotenv": "^17.2.3", "next": "16.0.3", + "prisma": "^6.19.0", "react": "19.2.0", "react-dom": "19.2.0" }, @@ -19,6 +22,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "tailwindcss": "^4", + "tsx": "^4.20.6", "typescript": "^5" } }, @@ -208,6 +212,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", @@ -858,6 +1304,91 @@ "node": ">= 10" } }, + "node_modules/@prisma/client": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.0.tgz", + "integrity": "sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.0.tgz", + "integrity": "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==", + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.0.tgz", + "integrity": "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.0.tgz", + "integrity": "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.0", + "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", + "@prisma/fetch-engine": "6.19.0", + "@prisma/get-platform": "6.19.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773.tgz", + "integrity": "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.0.tgz", + "integrity": "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.0", + "@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", + "@prisma/get-platform": "6.19.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.0.tgz", + "integrity": "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1168,6 +1699,46 @@ "@types/react": "^19.2.0" } }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001754", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", @@ -1188,12 +1759,51 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/csstype": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.1.tgz", @@ -1201,6 +1811,27 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1211,6 +1842,37 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -1225,6 +1887,121 @@ "node": ">=10.13.0" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1236,7 +2013,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -1611,12 +2387,66 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.2", + "pathe": "^2.0.3", + "pkg-types": "^2.3.0", + "tinyexec": "^1.0.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -1646,6 +2476,57 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prisma": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.0.tgz", + "integrity": "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.0", + "@prisma/engines": "6.19.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -1667,6 +2548,29 @@ "react": "^19.2.0" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1784,17 +2688,46 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 9dd1f54..93f8a75 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,24 @@ "build": "next build", "start": "next start", "lint": "biome check", - "format": "biome format --write" + "format": "biome format --write", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev", + "db:migrate:deploy": "prisma migrate deploy", + "db:migrate:reset": "prisma migrate reset", + "db:seed": "tsx prisma/seed.ts", + "db:studio": "prisma studio", + "db:push": "prisma db push", + "db:pull": "prisma db pull" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" }, "dependencies": { + "@prisma/client": "^6.19.0", + "dotenv": "^17.2.3", "next": "16.0.3", + "prisma": "^6.19.0", "react": "19.2.0", "react-dom": "19.2.0" }, @@ -21,6 +35,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "tailwindcss": "^4", + "tsx": "^4.20.6", "typescript": "^5" } } diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..410cb59 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,13 @@ +import { defineConfig, env } from "prisma/config"; +import "dotenv/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + }, + engine: "classic", + datasource: { + url: env("DATABASE_URL"), + }, +}); diff --git a/prisma/README.md b/prisma/README.md new file mode 100644 index 0000000..f7927c5 --- /dev/null +++ b/prisma/README.md @@ -0,0 +1,162 @@ +# Prisma 데이터베이스 관리 가이드 + +## 📋 개요 + +이 문서는 Prisma를 사용한 데이터베이스 스키마 관리 및 마이그레이션 워크플로우를 설명합니다. + +## 🚀 개발 환경 워크플로우 + +### 1. 스키마 변경 후 데이터베이스 최신화 + +#### 방법 A: 마이그레이션 생성 및 적용 (권장) +```bash +# 1. 스키마 변경 (prisma/schema.prisma 수정) + +# 2. 마이그레이션 생성 및 적용 +npm run db:migrate + +# 마이그레이션 이름을 지정하려면: +npx prisma migrate dev --name add_new_field +``` + +이 명령어는: +- 마이그레이션 파일 생성 +- 데이터베이스에 마이그레이션 적용 +- Prisma Client 자동 재생성 + +#### 방법 B: 개발 중 빠른 프로토타이핑 (데이터 손실 가능) +```bash +# 스키마 변경 후 즉시 적용 (마이그레이션 파일 생성 안 함) +npm run db:push +``` + +⚠️ **주의**: `db:push`는 프로덕션 환경에서는 사용하지 마세요! + +### 2. Seed 데이터 재실행 + +```bash +# Seed 데이터 삽입 +npm run db:seed + +# 또는 마이그레이션 리셋 후 자동으로 seed 실행 +npm run db:migrate:reset +``` + +### 3. 전체 초기화 (개발 환경 전용) + +```bash +# 데이터베이스 완전히 리셋 + 마이그레이션 재적용 + seed 실행 +npm run db:migrate:reset +``` + +⚠️ **주의**: 모든 데이터가 삭제됩니다! + +## 📦 프로덕션 환경 워크플로우 + +### 1. 마이그레이션 배포 + +```bash +# 생성된 마이그레이션 파일들을 프로덕션 DB에 적용 +npm run db:migrate:deploy +``` + +이 명령어는: +- 마이그레이션 파일만 적용 (새로운 마이그레이션 생성 안 함) +- 안전하게 프로덕션에 적용 가능 + +### 2. Prisma Client 재생성 + +```bash +npm run db:generate +``` + +## 🔄 일반적인 워크플로우 시나리오 + +### 시나리오 1: 새로운 필드 추가 + +```bash +# 1. schema.prisma에 필드 추가 +# 예: model User { ... newField String? } + +# 2. 마이그레이션 생성 및 적용 +npm run db:migrate --name add_new_field + +# 3. (선택) Seed 데이터 업데이트 필요 시 +npm run db:seed +``` + +### 시나리오 2: 관계 추가/변경 + +```bash +# 1. schema.prisma에 관계 추가 +# 2. 마이그레이션 생성 +npm run db:migrate --name add_relation + +# 3. 기존 데이터 마이그레이션 필요 시 수동으로 처리 +``` + +### 시나리오 3: 개발 중 스키마 실험 + +```bash +# 빠르게 스키마 변경 테스트 (마이그레이션 파일 생성 안 함) +npm run db:push + +# 만족스러우면 마이그레이션 생성 +npm run db:migrate --name experimental_changes +``` + +## 🛠️ 유용한 명령어 + +### Prisma Studio (데이터베이스 GUI) + +```bash +npm run db:studio +``` + +브라우저에서 데이터베이스를 시각적으로 확인하고 편집할 수 있습니다. + +### 기존 데이터베이스에서 스키마 가져오기 + +```bash +npm run db:pull +``` + +기존 데이터베이스 구조를 분석하여 `schema.prisma`를 생성합니다. + +## 📝 마이그레이션 파일 관리 + +- 마이그레이션 파일은 `prisma/migrations/` 폴더에 저장됩니다 +- 각 마이그레이션은 타임스탬프와 이름으로 식별됩니다 +- 마이그레이션 파일은 Git에 커밋해야 합니다 +- 팀원들과 마이그레이션을 공유하여 동일한 스키마를 유지합니다 + +## ⚠️ 주의사항 + +1. **프로덕션 환경**에서는 절대 `db:push`나 `db:migrate:reset`을 사용하지 마세요 +2. **마이그레이션 파일**은 항상 Git에 커밋하세요 +3. **스키마 변경 전**에 백업을 권장합니다 +4. **Seed 데이터**는 개발 환경에서만 사용하세요 + +## 🔍 문제 해결 + +### 마이그레이션 충돌 시 + +```bash +# 마이그레이션 상태 확인 +npx prisma migrate status + +# 문제 해결 후 +npm run db:migrate:resolve +``` + +### Prisma Client가 최신이 아닐 때 + +```bash +npm run db:generate +``` + +## 📚 추가 리소스 + +- [Prisma 마이그레이션 가이드](https://www.prisma.io/docs/concepts/components/prisma-migrate) +- [Prisma CLI 참조](https://www.prisma.io/docs/reference/api-reference/command-reference) + diff --git a/prisma/migrations/20251121074818_new/migration.sql b/prisma/migrations/20251121074818_new/migration.sql new file mode 100644 index 0000000..e0ede73 --- /dev/null +++ b/prisma/migrations/20251121074818_new/migration.sql @@ -0,0 +1,311 @@ +-- 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; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..2728af9 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,313 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ============================================ +// 사용자 관련 모델 +// ============================================ + +/// 사용자 (User) +/// 권한 설정 페이지, 로그인/회원가입에서 사용 +model User { + id String @id @default(uuid()) + email String @unique // 이메일 (아이디로 사용) + password String // 비밀번호 (해시화되어 저장) + name String // 성명 + phone String? // 휴대폰 번호 + gender String? // 성별 (M/F) + birthdate DateTime? // 생년월일 + role UserRole @default(LEARNER) // 권한: 학습자, 관리자 + status UserStatus @default(ACTIVE) // 계정 상태: 활성화, 비활성화 + joinDate DateTime @default(now()) // 가입일 + + // 관계 + createdCourses Course[] @relation("CourseCreator") + instructedCourses Course[] @relation("CourseInstructor") + createdLessons Lesson[] @relation("LessonCreator") + createdNotices Notice[] @relation("NoticeWriter") + enrollments Enrollment[] // 수강 등록 + certificates Certificate[] // 수료증 + logs Log[] // 로그 기록 + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([email]) + @@index([role]) + @@index([status]) + @@map("users") +} + +enum UserRole { + LEARNER // 학습자 + ADMIN // 관리자 (강사 권한 포함) +} + +enum UserStatus { + ACTIVE // 활성화 + INACTIVE // 비활성화 +} + +// ============================================ +// 교육과정 관련 모델 +// ============================================ + +/// 교육과정 (Course) +/// 교육과정 관리 페이지에서 사용 +model Course { + id String @id @default(uuid()) + courseName String // 교육과정명 + instructorId String // 강사 ID (User의 ADMIN 역할) + instructor User @relation("CourseInstructor", fields: [instructorId], references: [id]) + createdById String // 등록자 ID + createdBy User @relation("CourseCreator", fields: [createdById], references: [id]) + createdAt DateTime @default(now()) // 생성일 + + // 관계 + lessons Lesson[] // 강좌 목록 + + updatedAt DateTime @updatedAt + + @@index([instructorId]) + @@index([createdById]) + @@index([createdAt]) + @@map("courses") +} + +/// 강좌 (Lesson) +/// 강좌 관리 페이지에서 사용 +model Lesson { + id String @id @default(uuid()) + courseId String // 교육과정 ID + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + lessonName String // 강좌명 + learningGoal String? @db.Text // 학습 목표 (최대 1000자) + createdById String // 등록자 ID + createdBy User @relation("LessonCreator", fields: [createdById], references: [id]) + createdAt DateTime @default(now()) // 등록일 + + // 관계 + videos LessonVideo[] // 강좌 영상 + vrContents LessonVRContent[] // VR 콘텐츠 + questions Question[] // 학습 평가 문제 + attachments LessonAttachment[] // 첨부파일 + enrollments Enrollment[] // 수강 등록 + + updatedAt DateTime @updatedAt + + @@index([courseId]) + @@index([createdById]) + @@index([createdAt]) + @@map("lessons") +} + +/// 강좌 영상 (LessonVideo) +/// 강좌 등록 시 첨부되는 영상 파일 (최대 10개, 30MB 미만) +model LessonVideo { + id String @id @default(uuid()) + lessonId String // 강좌 ID + lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) + fileName String // 파일명 + filePath String // 파일 저장 경로 + fileSize Int // 파일 크기 (bytes) + order Int // 순서 + createdAt DateTime @default(now()) + + @@index([lessonId]) + @@map("lesson_videos") +} + +/// VR 콘텐츠 (LessonVRContent) +/// 강좌 등록 시 첨부되는 VR 콘텐츠 파일 (최대 10개, 30MB 미만) +model LessonVRContent { + id String @id @default(uuid()) + lessonId String // 강좌 ID + lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) + fileName String // 파일명 + filePath String // 파일 저장 경로 + fileSize Int // 파일 크기 (bytes) + order Int // 순서 + createdAt DateTime @default(now()) + + @@index([lessonId]) + @@map("lesson_vr_contents") +} + +/// 강좌 첨부파일 (LessonAttachment) +/// 강좌 관련 기타 첨부파일 +model LessonAttachment { + id String @id @default(uuid()) + lessonId String // 강좌 ID + lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) + fileName String // 파일명 + filePath String // 파일 저장 경로 + fileSize Int // 파일 크기 (bytes) + createdAt DateTime @default(now()) + + @@index([lessonId]) + @@map("lesson_attachments") +} + +// ============================================ +// 학습 평가 관련 모델 +// ============================================ + +/// 문제 (Question) +/// 문제 은행 페이지에서 사용, 강좌별 학습 평가 문제 +model Question { + id String @id @default(uuid()) + lessonId String? // 강좌 ID (선택적, 문제 은행에만 있을 수도 있음) + lesson Lesson? @relation(fields: [lessonId], references: [id], onDelete: SetNull) + question String @db.Text // 문제 내용 + type QuestionType @default(MULTIPLE_CHOICE) // 문제 유형 + options Json? // 선택지 (객관식인 경우) + correctAnswer String @db.Text // 정답 + explanation String? @db.Text // 해설 + points Int @default(1) // 배점 + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([lessonId]) + @@map("questions") +} + +enum QuestionType { + MULTIPLE_CHOICE // 객관식 + SHORT_ANSWER // 단답형 + ESSAY // 서술형 +} + +// ============================================ +// 공지사항 관련 모델 +// ============================================ + +/// 공지사항 (Notice) +/// 공지사항 관리 페이지에서 사용 +model Notice { + id String @id @default(uuid()) + title String // 제목 + content String @db.Text // 내용 (최대 1000자) + writerId String // 작성자 ID + writer User @relation("NoticeWriter", fields: [writerId], references: [id]) + views Int @default(0) // 조회수 + hasAttachment Boolean @default(false) // 첨부파일 여부 + date DateTime @default(now()) // 게시일 + + // 관계 + attachment NoticeAttachment? // 첨부파일 + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([writerId]) + @@index([date]) + @@map("notices") +} + +/// 공지사항 첨부파일 (NoticeAttachment) +/// 공지사항에 첨부되는 파일 (최대 1개, 30MB 미만) +model NoticeAttachment { + id String @id @default(uuid()) + noticeId String @unique // 공지사항 ID + notice Notice @relation(fields: [noticeId], references: [id], onDelete: Cascade) + fileName String // 파일명 + filePath String // 파일 저장 경로 + fileSize Int // 파일 크기 (bytes) + createdAt DateTime @default(now()) + + @@map("notice_attachments") +} + +// ============================================ +// 학습 자료 관련 모델 +// ============================================ + +/// 학습 자료 (Resource) +/// 학습 자료실 페이지에서 사용 +model Resource { + id String @id @default(uuid()) + title String // 제목 + description String? @db.Text // 설명 + filePath String? // 파일 경로 (파일이 있는 경우) + fileName String? // 파일명 + fileSize Int? // 파일 크기 (bytes) + category String? // 카테고리 + views Int @default(0) // 조회수 + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([category]) + @@index([createdAt]) + @@map("resources") +} + +// ============================================ +// 수강 및 수료 관련 모델 +// ============================================ + +/// 수강 등록 (Enrollment) +/// 사용자가 강좌를 수강하는 관계 +model Enrollment { + id String @id @default(uuid()) + userId String // 사용자 ID + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + lessonId String // 강좌 ID + lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) + progress Int @default(0) // 학습 진행률 (0-100) + completedAt DateTime? // 완료일 + enrolledAt DateTime @default(now()) // 등록일 + + @@unique([userId, lessonId]) + @@index([userId]) + @@index([lessonId]) + @@map("enrollments") +} + +/// 수료증 (Certificate) +/// 수료증 발급/검증키 관리 페이지에서 사용 +model Certificate { + id String @id @default(uuid()) + userId String // 사용자 ID + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + lessonId String? // 강좌 ID (강좌 완료 시 발급) + courseId String? // 교육과정 ID (과정 완료 시 발급) + verificationKey String @unique // 검증 키 + issuedAt DateTime @default(now()) // 발급일 + + @@index([userId]) + @@index([verificationKey]) + @@map("certificates") +} + +// ============================================ +// 로그 관련 모델 +// ============================================ + +/// 로그 (Log) +/// 로그/접속 기록 페이지에서 사용 +model Log { + id String @id @default(uuid()) + userId String? // 사용자 ID (로그인한 경우) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + action String // 액션 (예: LOGIN, LOGOUT, VIEW_LESSON, etc.) + ipAddress String? // IP 주소 + userAgent String? // User Agent + details Json? // 추가 상세 정보 + createdAt DateTime @default(now()) // 기록 시간 + + @@index([userId]) + @@index([action]) + @@index([createdAt]) + @@map("logs") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..68c8a26 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,205 @@ +import { PrismaClient, UserRole, UserStatus } from '@prisma/client'; + +const prisma = new PrismaClient(); + +// 간단한 비밀번호 해시 함수 (실제 프로덕션에서는 bcrypt 사용 권장) +function hashPassword(password: string): string { + // 개발 환경용 간단한 해시 (실제로는 bcrypt 사용) + // 모든 사용자의 기본 비밀번호는 "password123"으로 설정 + return '$2a$10$placeholder_hash_for_development_only'; +} + +async function main() { + console.log('🌱 Seeding database...'); + + // 1. 사용자 데이터 생성 + const mockUsers = [ + { id: "1", joinDate: "2024-01-15", name: "김민준", email: "user1@example.com", role: "learner", status: "active" }, + { id: "2", joinDate: "2024-01-20", name: "이서준", email: "user2@example.com", role: "learner", status: "active" }, + { id: "3", joinDate: "2024-02-05", name: "박도윤", email: "user3@example.com", role: "learner", status: "inactive" }, + { id: "4", joinDate: "2024-02-10", name: "최예준", email: "user4@example.com", role: "instructor", status: "active" }, + { id: "5", joinDate: "2024-02-15", name: "정시우", email: "user5@example.com", role: "instructor", status: "active" }, + { id: "6", joinDate: "2024-02-20", name: "강하준", email: "user6@example.com", role: "learner", status: "active" }, + { id: "7", joinDate: "2024-03-01", name: "조주원", email: "user7@example.com", role: "admin", status: "active" }, + { id: "8", joinDate: "2024-03-05", name: "윤지호", email: "user8@example.com", role: "learner", status: "active" }, + { id: "9", joinDate: "2024-03-10", name: "장준서", email: "user9@example.com", role: "learner", status: "inactive" }, + { id: "10", joinDate: "2024-03-15", name: "임건우", email: "user10@example.com", role: "instructor", status: "active" }, + { id: "11", joinDate: "2024-03-20", name: "한서연", email: "user11@example.com", role: "learner", status: "active" }, + { id: "12", joinDate: "2024-04-01", name: "오서윤", email: "user12@example.com", role: "learner", status: "active" }, + { id: "13", joinDate: "2024-04-05", name: "서지우", email: "user13@example.com", role: "instructor", status: "inactive" }, + { id: "14", joinDate: "2024-04-10", name: "신서현", email: "user14@example.com", role: "learner", status: "active" }, + { id: "15", joinDate: "2024-04-15", name: "권민서", email: "user15@example.com", role: "admin", status: "active" }, + { id: "16", joinDate: "2024-04-20", name: "황하은", email: "user16@example.com", role: "learner", status: "active" }, + { id: "17", joinDate: "2024-05-01", name: "안예은", email: "user17@example.com", role: "learner", status: "inactive" }, + { id: "18", joinDate: "2024-05-05", name: "송윤서", email: "user18@example.com", role: "instructor", status: "active" }, + { id: "19", joinDate: "2024-05-10", name: "전채원", email: "user19@example.com", role: "learner", status: "active" }, + { id: "20", joinDate: "2024-05-15", name: "홍지원", email: "user20@example.com", role: "learner", status: "active" }, + { id: "21", joinDate: "2024-05-20", name: "김민수", email: "user21@example.com", role: "instructor", status: "active" }, + { id: "22", joinDate: "2024-06-01", name: "이영희", email: "user22@example.com", role: "learner", status: "active" }, + { id: "23", joinDate: "2024-06-05", name: "박철수", email: "user23@example.com", role: "learner", status: "inactive" }, + { id: "24", joinDate: "2024-06-10", name: "최수진", email: "user24@example.com", role: "admin", status: "active" }, + { id: "25", joinDate: "2024-06-15", name: "정대현", email: "user25@example.com", role: "instructor", status: "active" }, + { id: "26", joinDate: "2024-06-20", name: "강미영", email: "user26@example.com", role: "learner", status: "active" }, + { id: "27", joinDate: "2024-07-01", name: "조성호", email: "user27@example.com", role: "learner", status: "active" }, + { id: "28", joinDate: "2024-07-05", name: "윤지은", email: "user28@example.com", role: "instructor", status: "inactive" }, + { id: "29", joinDate: "2024-07-10", name: "장현우", email: "user29@example.com", role: "learner", status: "active" }, + { id: "30", joinDate: "2024-07-15", name: "임소영", email: "user30@example.com", role: "learner", status: "active" }, + ]; + + // 사용자 생성 및 ID 매핑 저장 + const userMap = new Map(); + + for (const userData of mockUsers) { + const role = userData.role === 'instructor' ? UserRole.ADMIN : + userData.role === 'admin' ? UserRole.ADMIN : + UserRole.LEARNER; + const status = userData.status === 'active' ? UserStatus.ACTIVE : UserStatus.INACTIVE; + + const user = await prisma.user.upsert({ + where: { email: userData.email }, + update: {}, + create: { + email: userData.email, + password: hashPassword('password123'), // 기본 비밀번호 + name: userData.name, + role, + status, + joinDate: new Date(userData.joinDate), + }, + }); + + userMap.set(userData.id, user.id); + } + + console.log(`✅ Created ${userMap.size} users`); + + // 관리자 계정 찾기 (공지사항 작성자용) + const adminUsers = await prisma.user.findMany({ + where: { role: UserRole.ADMIN, status: UserStatus.ACTIVE }, + }); + const defaultAdmin = adminUsers[0]; + + // 2. 교육과정 데이터 생성 + const mockCourses = [ + { id: "1", courseName: "웹 개발 기초", instructorName: "최예준", createdAt: "2024-01-15", createdBy: "관리자" }, + { id: "2", courseName: "React 실전 프로젝트", instructorName: "정시우", createdAt: "2024-02-20", createdBy: "관리자" }, + { id: "3", courseName: "데이터베이스 설계", instructorName: "임건우", createdAt: "2024-03-10", createdBy: "관리자" }, + { id: "4", courseName: "Node.js 백엔드 개발", instructorName: "송윤서", createdAt: "2024-03-25", createdBy: "관리자" }, + { id: "5", courseName: "TypeScript 마스터", instructorName: "김민수", createdAt: "2024-04-05", createdBy: "관리자" }, + { id: "6", courseName: "UI/UX 디자인 기초", instructorName: "정대현", createdAt: "2024-04-18", createdBy: "관리자" }, + { id: "7", courseName: "모바일 앱 개발", instructorName: "최예준", createdAt: "2024-05-02", createdBy: "관리자" }, + { id: "8", courseName: "클라우드 인프라", instructorName: "정시우", createdAt: "2024-05-15", createdBy: "관리자" }, + { id: "9", courseName: "머신러닝 입문", instructorName: "임건우", createdAt: "2024-06-01", createdBy: "관리자" }, + { id: "10", courseName: "DevOps 실무", instructorName: "송윤서", createdAt: "2024-06-20", createdBy: "관리자" }, + ]; + + const courseMap = new Map(); + + for (const courseData of mockCourses) { + // 강사 이름으로 사용자 찾기 + const instructor = await prisma.user.findFirst({ + where: { name: courseData.instructorName, role: UserRole.ADMIN }, + }); + + if (!instructor) { + console.warn(`⚠️ Instructor not found: ${courseData.instructorName}`); + continue; + } + + const course = await prisma.course.create({ + data: { + courseName: courseData.courseName, + instructorId: instructor.id, + createdById: defaultAdmin?.id || instructor.id, + createdAt: new Date(courseData.createdAt), + }, + }); + + courseMap.set(courseData.id, course.id); + } + + console.log(`✅ Created ${courseMap.size} courses`); + + // 3. 공지사항 데이터 생성 + const mockNotices = [ + { + id: 2, + title: '공지사항 제목이 노출돼요', + date: '2025-09-10', + views: 1230, + writer: '문지호', + content: [ + '사이트 이용 관련 주요 변경 사항을 안내드립니다.', + '변경되는 내용은 공지일자로부터 즉시 적용됩니다.', + ], + }, + { + id: 1, + title: '📢 방사선학 온라인 강의 수강 안내 및 필수 공지', + date: '2025-06-28', + views: 594, + writer: '문지호', + hasAttachment: true, + content: [ + '온라인 강의 수강 방법과 필수 확인 사항을 안내드립니다.', + '수강 기간 및 출석, 과제 제출 관련 정책을 반드시 확인해 주세요.', + ], + }, + ]; + + // 공지사항 작성자 찾기 또는 생성 + let noticeWriter = await prisma.user.findFirst({ + where: { name: '문지호' }, + }); + + if (!noticeWriter) { + noticeWriter = await prisma.user.create({ + data: { + email: 'munjih@example.com', + password: hashPassword('password123'), + name: '문지호', + role: UserRole.ADMIN, + status: UserStatus.ACTIVE, + }, + }); + } + + for (const noticeData of mockNotices) { + const notice = await prisma.notice.create({ + data: { + title: noticeData.title, + content: noticeData.content?.join('\n') || '', + writerId: noticeWriter.id, + views: noticeData.views, + hasAttachment: noticeData.hasAttachment || false, + date: new Date(noticeData.date), + }, + }); + + // 첨부파일이 있는 경우 + if (noticeData.hasAttachment) { + await prisma.noticeAttachment.create({ + data: { + noticeId: notice.id, + fileName: '공지사항_첨부파일.pdf', + filePath: '/uploads/notices/sample.pdf', + fileSize: 1024000, // 1MB + }, + }); + } + } + + console.log(`✅ Created ${mockNotices.length} notices`); + + console.log('🎉 Seeding completed!'); +} + +main() + .catch((e) => { + console.error('❌ Error seeding database:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); + diff --git a/src/app/api/README.md b/src/app/api/README.md new file mode 100644 index 0000000..9fba067 --- /dev/null +++ b/src/app/api/README.md @@ -0,0 +1,211 @@ +# 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 추가 + diff --git a/src/app/api/courses/route.ts b/src/app/api/courses/route.ts new file mode 100644 index 0000000..b2407ce --- /dev/null +++ b/src/app/api/courses/route.ts @@ -0,0 +1,126 @@ +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 } + ); + } +} + diff --git a/src/app/api/examples.ts b/src/app/api/examples.ts new file mode 100644 index 0000000..0ecbfe7 --- /dev/null +++ b/src/app/api/examples.ts @@ -0,0 +1,272 @@ +/** + * 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( + apiCall: () => Promise +): 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); +// } + diff --git a/src/app/api/lessons/route.ts b/src/app/api/lessons/route.ts new file mode 100644 index 0000000..8bf4010 --- /dev/null +++ b/src/app/api/lessons/route.ts @@ -0,0 +1,127 @@ +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 } + ); + } +} + diff --git a/src/app/api/notices/route.ts b/src/app/api/notices/route.ts new file mode 100644 index 0000000..544aa02 --- /dev/null +++ b/src/app/api/notices/route.ts @@ -0,0 +1,109 @@ +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 } + ); + } +} + diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..91bbecb --- /dev/null +++ b/src/app/api/users/route.ts @@ -0,0 +1,115 @@ +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 } + ); + } +} + diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..0eebf59 --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,14 @@ +import { PrismaClient } from '@prisma/client'; + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined; +}; + +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], + }); + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; +