Compare commits
14 Commits
3415286425
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c31eca4b58 | ||
|
|
d3548cf7ff | ||
|
|
29ba4867bf | ||
|
|
a1f70a5b46 | ||
|
|
b3f2da203b | ||
|
|
cf72281c6d | ||
|
|
525a4fc1f2 | ||
|
|
5f16515dab | ||
|
|
fb2956b0ac | ||
|
|
d14ff1997c | ||
|
|
288e05f691 | ||
|
|
6ab2dae634 | ||
|
|
64d0314873 | ||
|
|
ecb897009a |
13
.env.example
13
.env.example
@@ -1,18 +1,17 @@
|
|||||||
# ─── Database ───────────────────────────────────────────────
|
# ─── Database (Docker → data/postgres 영구 저장, 포트 54320) ───
|
||||||
DB_USER=eee_admin
|
DB_USER=eee_admin
|
||||||
DB_PASSWORD=eee_password
|
DB_PASSWORD=eee_password
|
||||||
DB_NAME=eee_dashboard
|
DB_NAME=eee_dashboard
|
||||||
DB_PORT=5432
|
DB_PORT=54320
|
||||||
DATABASE_URL="postgresql://eee_admin:eee_password@localhost:5432/eee_dashboard"
|
DATABASE_URL="postgresql://eee_admin:eee_password@localhost:54320/eee_dashboard"
|
||||||
|
|
||||||
# ─── Backend ─────────────────────────────────────────────────
|
# ─── Backend ─────────────────────────────────────────────────
|
||||||
PORT=4000
|
PORT=4000
|
||||||
FRONTEND_URL=http://172.16.8.248:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
|
||||||
# JWT 시크릿 (운영 시 반드시 강력한 랜덤 문자열로 교체)
|
|
||||||
JWT_SECRET=change_this_secret_in_production
|
JWT_SECRET=change_this_secret_in_production
|
||||||
JWT_EXPIRES_IN=7d
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
# ─── File Upload ─────────────────────────────────────────────
|
# ─── Paths (프로젝트 루트 기준, backend/.env 와 동일) ────────
|
||||||
UPLOAD_DIR=../uploads
|
UPLOAD_DIR=../uploads
|
||||||
MAX_FILE_SIZE_MB=20
|
HR_DATA_PATH=../data/seed/hr-data.json
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -2,9 +2,11 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
.env
|
.env
|
||||||
data/
|
backend/.env
|
||||||
uploads/*
|
|
||||||
!uploads/.gitkeep
|
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
frontend/.lan-ip
|
||||||
|
# DB lock files (recreated on server start)
|
||||||
|
data/postgres/postmaster.pid
|
||||||
|
data/postgres/postmaster.opts
|
||||||
|
|||||||
5
Gitea업로드.bat
Normal file
5
Gitea업로드.bat
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
cd /d "%~dp0"
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\gitea-upload.ps1"
|
||||||
|
pause
|
||||||
33
PC이전.txt
Normal file
33
PC이전.txt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
EENE Dashboard — 이 폴더만 통째로 옮기면 됩니다
|
||||||
|
============================================
|
||||||
|
|
||||||
|
■ 반드시 함께 복사
|
||||||
|
data\postgres\ DB (업무·팀원·허브 설정·첨부 정보)
|
||||||
|
data\seed\ HR seed (hr-data.json — 최초 import용)
|
||||||
|
uploads\ 첨부·팀 사진
|
||||||
|
backend\.env 설정 (없으면 서버시작 시 자동 생성)
|
||||||
|
|
||||||
|
■ 새 PC 준비
|
||||||
|
- Node.js 20+
|
||||||
|
- PostgreSQL 16 (Windows) 또는 Docker Desktop
|
||||||
|
|
||||||
|
■ 실행 (2개 파일)
|
||||||
|
서버시작.bat → DB(data\postgres) + 서버 + 화면
|
||||||
|
서버종료.bat → API/WEB만 종료 (DB 유지)
|
||||||
|
서버종료.bat db → DB까지 중지
|
||||||
|
|
||||||
|
■ 첫 실행 시
|
||||||
|
예전 Windows PostgreSQL(5432)에 데이터가 있으면
|
||||||
|
자동으로 data\postgres 로 옮깁니다.
|
||||||
|
|
||||||
|
■ 접속
|
||||||
|
http://localhost:3000
|
||||||
|
|
||||||
|
■ PC 옮긴 뒤 [2/4] DB에서 멈출 때
|
||||||
|
1. PostgreSQL 16 설치 (5432 기본값 그대로 OK)
|
||||||
|
2. 서버종료.bat db 실행 후 서버시작.bat 재실행
|
||||||
|
3. 안 되면 data\postgres\postmaster.pid 삭제 후 재실행
|
||||||
|
4. data\postgres\pg.log 마지막 줄 확인
|
||||||
|
- 버전 불일치면 새 PC도 PostgreSQL 16 사용
|
||||||
|
|
||||||
|
자세한 내용: README.md
|
||||||
230
README.md
230
README.md
@@ -1,162 +1,88 @@
|
|||||||
# EENE 인재성장팀 대시보드
|
# EENE 인재성장팀 대시보드 (로컬 전용)
|
||||||
|
|
||||||
## 사전 설치 필요
|
**이 폴더 통째로** USB·OneDrive 등으로 옮겨도 됩니다.
|
||||||
|
`data/postgres/` + `uploads/` 를 함께 복사하면 데이터가 그대로 유지됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실행 (루트 bat 2개)
|
||||||
|
|
||||||
|
| 파일 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| **`서버시작.bat`** | DB 시작 → 스키마 → (빈 DB면 샘플) → API + 화면 |
|
||||||
|
| **`서버종료.bat`** | API/WEB 종료 (DB·데이터 유지) |
|
||||||
|
| **`서버종료.bat db`** | DB까지 중지 |
|
||||||
|
|
||||||
|
접속: **http://localhost:3000**
|
||||||
|
|
||||||
|
PC 이동 요약: **`PC이전.txt`**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 저장 위치
|
||||||
|
|
||||||
|
| 내용 | 경로 |
|
||||||
|
|------|------|
|
||||||
|
| DB | `data/postgres/` (업무·팀원·허브 설정·첨부 정보) |
|
||||||
|
| HR seed (최초 import) | `data/seed/hr-data.json` |
|
||||||
|
| 첨부 | `uploads/` |
|
||||||
|
| 팀 사진 | `uploads/team/` |
|
||||||
|
| API 설정 | `backend/.env` (없으면 `서버시작.bat`이 자동 생성) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 최초 1회 (수동)
|
||||||
|
|
||||||
- [Node.js 20+](https://nodejs.org)
|
- [Node.js 20+](https://nodejs.org)
|
||||||
- [Docker Desktop](https://www.docker.com/products/docker-desktop/)
|
- **PostgreSQL 16** (Windows 설치) **또는** [Docker Desktop](https://www.docker.com/products/docker-desktop/)
|
||||||
|
|
||||||
|
DB는 **항상 `data/postgres/`** 에 저장됩니다 (포트 **54320**).
|
||||||
|
Windows에 PostgreSQL이 이미 5432로 돌아가도 충돌하지 않습니다.
|
||||||
|
|
||||||
|
그다음 **`서버시작.bat`** 더블클릭.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 최초 실행 순서
|
## 기본 계정 (샘플)
|
||||||
|
|
||||||
### 1. 환경 변수 설정
|
| 이메일 | 비밀번호 |
|
||||||
|
|--------|----------|
|
||||||
|
| admin@eene.com | admin1234! |
|
||||||
|
| member@eene.com | member1234! |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 폴더 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
EENE_Dashboard_0608/
|
||||||
|
├── 서버시작.bat / 서버종료.bat
|
||||||
|
├── data/
|
||||||
|
│ ├── postgres/ ← DB (실행 데이터)
|
||||||
|
│ └── seed/hr-data.json ← HR 원본 (seed·import)
|
||||||
|
├── uploads/ ← 파일
|
||||||
|
├── backend/ ← API
|
||||||
|
├── frontend/ ← 화면
|
||||||
|
└── _archive/ ← (나중) 배포용 보관
|
||||||
|
```
|
||||||
|
|
||||||
|
배포는 추후 별도 구축. `_archive/`에 예전 배포 설정만 보관.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 듀얼 모니터
|
||||||
|
|
||||||
|
- 왼쪽: `http://localhost:3000`
|
||||||
|
- 오른쪽: `http://localhost:3000/detail`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## npm (터미널 선호 시)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 루트에 .env 파일이 없을 경우 복사
|
npm run local:db # Docker DB (data/postgres)
|
||||||
copy .env.example .env
|
npm run local:db:stop # Docker DB 중지
|
||||||
|
npm run local:setup # 스키마 + 빈 DB seed
|
||||||
# 백엔드 .env 확인
|
npm run local:api # :4000
|
||||||
# backend\.env 파일은 이미 기본값으로 생성되어 있음
|
npm run local:web # :3000
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. PostgreSQL 실행 (Docker)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 프로젝트 루트에서 실행
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# 실행 확인
|
|
||||||
docker compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 백엔드 설치 및 실행
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
|
|
||||||
# 패키지 설치
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# DB 마이그레이션 (테이블 생성)
|
|
||||||
npm run db:migrate
|
|
||||||
|
|
||||||
# 샘플 데이터 입력
|
|
||||||
npm run db:seed
|
|
||||||
|
|
||||||
# 개발 서버 실행
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
백엔드가 `http://localhost:4000` 에서 실행됩니다.
|
|
||||||
|
|
||||||
### 4. 프론트엔드 설치 및 실행
|
|
||||||
|
|
||||||
새 터미널을 열고:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
|
|
||||||
# 패키지 설치
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# 개발 서버 실행
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
프론트엔드가 `http://localhost:3000` 에서 실행됩니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 서버(이 PC) IP 주소 확인
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# CMD 또는 PowerShell 에서 실행
|
|
||||||
ipconfig
|
|
||||||
# → "이더넷" 또는 "Wi-Fi" 항목의 IPv4 주소 확인
|
|
||||||
# 예: 192.168.1.100
|
|
||||||
```
|
|
||||||
|
|
||||||
팀원들이 접속할 주소:
|
|
||||||
- 대시보드: `http://172.16.8.248:3000`
|
|
||||||
- 업무 상세: `http://172.16.8.248:3000/detail`
|
|
||||||
|
|
||||||
### Windows 방화벽 포트 열기 (관리자 권한 CMD)
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
netsh advfirewall firewall add rule name="EEE Dashboard Frontend" dir=in action=allow protocol=TCP localport=3000
|
|
||||||
netsh advfirewall firewall add rule name="EEE Dashboard Backend" dir=in action=allow protocol=TCP localport=4000
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 듀얼 모니터 사용 방법
|
|
||||||
|
|
||||||
1. 브라우저 창을 두 개 엽니다.
|
|
||||||
2. **왼쪽 모니터**: `http://localhost:3000` (업무 목록 대시보드)
|
|
||||||
3. **오른쪽 모니터**: `http://localhost:3000/detail` (업무 상세 패널)
|
|
||||||
4. 왼쪽에서 업무를 클릭하면 오른쪽 창이 자동으로 해당 상세 내용을 표시합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 기본 계정 (seed 데이터)
|
|
||||||
|
|
||||||
| 이메일 | 비밀번호 | 역할 |
|
|
||||||
|---|---|---|
|
|
||||||
| admin@eee.com | admin1234! | 관리자 |
|
|
||||||
| member@eee.com | member1234! | 팀원 |
|
|
||||||
|
|
||||||
> 운영 시 반드시 비밀번호를 변경하세요.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 프로젝트 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
D:\EENE_Dashboard\
|
|
||||||
├── backend\ # Express + TypeScript API 서버
|
|
||||||
│ ├── prisma\
|
|
||||||
│ │ ├── schema.prisma # DB 스키마 (테이블 정의)
|
|
||||||
│ │ └── seed.ts # 초기 데이터
|
|
||||||
│ └── src\
|
|
||||||
│ ├── index.ts # 서버 진입점
|
|
||||||
│ ├── app.ts # Express 앱 설정
|
|
||||||
│ ├── socket.ts # Socket.io 핸들러
|
|
||||||
│ ├── lib\ # Prisma 클라이언트
|
|
||||||
│ ├── middleware\ # 파일 업로드, 에러 처리
|
|
||||||
│ └── routes\ # API 라우터
|
|
||||||
│ ├── tasks.ts # 업무 CRUD
|
|
||||||
│ ├── users.ts # 사용자 관리
|
|
||||||
│ ├── files.ts # 파일 업로드
|
|
||||||
│ └── kpi.ts # KPI 관리
|
|
||||||
│
|
|
||||||
├── frontend\ # React + TypeScript 앱
|
|
||||||
│ └── src\
|
|
||||||
│ ├── contexts\ # Socket 컨텍스트
|
|
||||||
│ ├── lib\ # API 클라이언트, 듀얼모니터 유틸
|
|
||||||
│ ├── pages\ # 각 페이지 컴포넌트
|
|
||||||
│ ├── types\ # TypeScript 타입 정의
|
|
||||||
│ └── router.tsx # 라우팅 설정
|
|
||||||
│
|
|
||||||
├── uploads\ # 업로드 파일 저장소
|
|
||||||
├── 서버시작.bat # 서버 실행
|
|
||||||
└── 서버종료.bat # 서버 종료
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API 엔드포인트
|
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
|
||||||
|---|---|---|
|
|
||||||
| POST | /api/auth/login | 로그인 |
|
|
||||||
| GET | /api/auth/me | 내 정보 조회 |
|
|
||||||
| GET | /api/tasks | 업무 목록 |
|
|
||||||
| POST | /api/tasks | 업무 등록 |
|
|
||||||
| PATCH | /api/tasks/:id | 업무 수정 |
|
|
||||||
| DELETE | /api/tasks/:id | 업무 삭제 |
|
|
||||||
| GET | /api/users | 사용자 목록 (관리자) |
|
|
||||||
| POST | /api/users | 사용자 생성 (관리자) |
|
|
||||||
| POST | /api/files/upload/:taskId | 파일 업로드 |
|
|
||||||
| DELETE | /api/files/:id | 파일 삭제 |
|
|
||||||
| GET | /api/kpi | KPI 조회 |
|
|
||||||
| POST | /api/kpi | KPI 등록 |
|
|
||||||
|
|||||||
12
_archive/.env.docker.example
Normal file
12
_archive/.env.docker.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Docker Compose 사용 시 (선택)
|
||||||
|
# 1. Windows PostgreSQL 서비스 중지 (5432 포트 충돌 방지)
|
||||||
|
# 2. backend\.env 의 DATABASE_URL 을 아래로 변경
|
||||||
|
# 3. docker compose up -d
|
||||||
|
|
||||||
|
DB_USER=eee_admin
|
||||||
|
DB_PASSWORD=eee_password
|
||||||
|
DB_NAME=eee_dashboard
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# backend\.env:
|
||||||
|
# DATABASE_URL="postgresql://eee_admin:eee_password@localhost:5432/eee_dashboard"
|
||||||
17
backend/.env.example
Normal file
17
backend/.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# 로컬 전용 — backend/.env 로 복사 후 사용 (서버시작.bat 이 자동 갱신)
|
||||||
|
# 모든 경로는 프로젝트 루트(EENE_Dashboard_0608) 기준
|
||||||
|
|
||||||
|
# DB — data/postgres (포트 54320)
|
||||||
|
DATABASE_URL="postgresql://eee_admin:eee_password@localhost:54320/eee_dashboard"
|
||||||
|
|
||||||
|
PORT=4000
|
||||||
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
|
||||||
|
JWT_SECRET=change_this_secret_in_production
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# 첨부 — uploads/ (프로젝트 루트, 파일 크기 상한 없음 — 디스크 여유만큼)
|
||||||
|
UPLOAD_DIR=../uploads
|
||||||
|
|
||||||
|
# HR seed — data/seed/hr-data.json (최초 seed·import-hr 공통)
|
||||||
|
HR_DATA_PATH=../data/seed/hr-data.json
|
||||||
253
backend/package-lock.json
generated
253
backend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "eene-dashboard-backend",
|
"name": "eene-dashboard-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ohah/hwpjs": "^0.1.0-rc.10",
|
||||||
"@prisma/client": "^6.0.0",
|
"@prisma/client": "^6.0.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"prisma": "^6.0.0",
|
||||||
"socket.io": "^4.8.0",
|
"socket.io": "^4.8.0",
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0"
|
||||||
},
|
},
|
||||||
@@ -29,11 +31,44 @@
|
|||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"prisma": "^6.0.0",
|
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/core": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.2",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||||
@@ -496,6 +531,163 @@
|
|||||||
"node-pre-gyp": "bin/node-pre-gyp"
|
"node-pre-gyp": "bin/node-pre-gyp"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@tybys/wasm-util": "^0.10.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ohah/hwpjs": {
|
||||||
|
"version": "0.1.0-rc.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ohah/hwpjs/-/hwpjs-0.1.0-rc.10.tgz",
|
||||||
|
"integrity": "sha512-eHtpBzqyj/qTr4HHUMNwBlZJBjRQ0gUJdUP7i6csGn0uKPhUu0LlV9l6eI2yB68NVD9T5OqLsmGDbLSv8RUDzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^14.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"hwpjs": "bin/hwpjs.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@ohah/hwpjs-darwin-arm64": "0.1.0-rc.10",
|
||||||
|
"@ohah/hwpjs-darwin-x64": "0.1.0-rc.10",
|
||||||
|
"@ohah/hwpjs-linux-x64-gnu": "0.1.0-rc.10",
|
||||||
|
"@ohah/hwpjs-wasm32-wasi": "0.1.0-rc.10",
|
||||||
|
"@ohah/hwpjs-win32-arm64-msvc": "0.1.0-rc.10",
|
||||||
|
"@ohah/hwpjs-win32-ia32-msvc": "0.1.0-rc.10",
|
||||||
|
"@ohah/hwpjs-win32-x64-msvc": "0.1.0-rc.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ohah/hwpjs-darwin-arm64": {
|
||||||
|
"version": "0.1.0-rc.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ohah/hwpjs-darwin-arm64/-/hwpjs-darwin-arm64-0.1.0-rc.10.tgz",
|
||||||
|
"integrity": "sha512-9CGqsI2XdhLLtEJeIP8JKGPYlALSIGcpoyp2Be0QYzzW6J4LVN1q1We58srxSwn3pfdyumIDEAgXU3FskyRSFQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ohah/hwpjs-darwin-x64": {
|
||||||
|
"version": "0.1.0-rc.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ohah/hwpjs-darwin-x64/-/hwpjs-darwin-x64-0.1.0-rc.10.tgz",
|
||||||
|
"integrity": "sha512-DW2r/SVCNIevGxA48dti3icWXkZmj+W3pq34VURp5FPTO8r2brMKhkywOWejThI7KI213ihlvt/+YIp3d5PrOg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ohah/hwpjs-linux-x64-gnu": {
|
||||||
|
"version": "0.1.0-rc.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ohah/hwpjs-linux-x64-gnu/-/hwpjs-linux-x64-gnu-0.1.0-rc.10.tgz",
|
||||||
|
"integrity": "sha512-Ph5ntwnufbBtvcJTRhtp/+W98Efvr3D4ZpTQ+mr5uzDE4h2g2K3yOGP8MAJD7YEzBF2WXQgWCRtD9jFxVDo/eA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ohah/hwpjs-wasm32-wasi": {
|
||||||
|
"version": "0.1.0-rc.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ohah/hwpjs-wasm32-wasi/-/hwpjs-wasm32-wasi-0.1.0-rc.10.tgz",
|
||||||
|
"integrity": "sha512-3icQYI6xmEXwYRqsiHitPTK14UvvYJHHF1LzcgT8gXBnbRk5CAAgQC6Z31gqmXbYRCV3LjGdnhZz4bTyFxEfZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@napi-rs/wasm-runtime": "^1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ohah/hwpjs-win32-arm64-msvc": {
|
||||||
|
"version": "0.1.0-rc.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ohah/hwpjs-win32-arm64-msvc/-/hwpjs-win32-arm64-msvc-0.1.0-rc.10.tgz",
|
||||||
|
"integrity": "sha512-EN6x0+VKZMnKRbImuSSCTk0oi5uA5802qNQf3VJ+p+VjZO0UbijthoNI7oT4F07F3hp4JYsqaXvi5KuXs0UTOA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ohah/hwpjs-win32-ia32-msvc": {
|
||||||
|
"version": "0.1.0-rc.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ohah/hwpjs-win32-ia32-msvc/-/hwpjs-win32-ia32-msvc-0.1.0-rc.10.tgz",
|
||||||
|
"integrity": "sha512-1dV3LF1vxAI5+MAw07izoF2YD1if3dUDRt+7E1C6zIoZk91l+5qyUCe8Z3UJOVTNfzl9ZT+ecpgSWevk7KaW3g==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ohah/hwpjs-win32-x64-msvc": {
|
||||||
|
"version": "0.1.0-rc.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ohah/hwpjs-win32-x64-msvc/-/hwpjs-win32-x64-msvc-0.1.0-rc.10.tgz",
|
||||||
|
"integrity": "sha512-PekyIPNmCB1h5iAPm4jIji1bERtFQ2bpXZhpha6XcbaPg8ExWWABViDzF8F/l8YKt4IFaf0AGaGXp03XDMvbhA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
||||||
@@ -522,7 +714,6 @@
|
|||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
|
||||||
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
|
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"c12": "3.1.0",
|
"c12": "3.1.0",
|
||||||
@@ -535,14 +726,12 @@
|
|||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
|
||||||
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
|
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines": {
|
"node_modules/@prisma/engines": {
|
||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
|
||||||
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
|
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
|
||||||
"devOptional": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -556,14 +745,12 @@
|
|||||||
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
|
||||||
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
|
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/fetch-engine": {
|
"node_modules/@prisma/fetch-engine": {
|
||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
|
||||||
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
|
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "6.19.3",
|
"@prisma/debug": "6.19.3",
|
||||||
@@ -575,7 +762,6 @@
|
|||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
|
||||||
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
|
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "6.19.3"
|
"@prisma/debug": "6.19.3"
|
||||||
@@ -591,9 +777,18 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
|
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/bcrypt": {
|
"node_modules/@types/bcrypt": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
|
||||||
@@ -990,7 +1185,6 @@
|
|||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||||
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
|
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
@@ -1048,7 +1242,6 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readdirp": "^4.0.1"
|
"readdirp": "^4.0.1"
|
||||||
@@ -1073,7 +1266,6 @@
|
|||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"consola": "^3.2.3"
|
"consola": "^3.2.3"
|
||||||
@@ -1088,6 +1280,15 @@
|
|||||||
"color-support": "bin.js"
|
"color-support": "bin.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "14.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||||
|
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -1113,14 +1314,12 @@
|
|||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||||
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/consola": {
|
"node_modules/consola": {
|
||||||
"version": "3.4.2",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||||
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^14.18.0 || >=16.10.0"
|
"node": "^14.18.0 || >=16.10.0"
|
||||||
@@ -1204,7 +1403,6 @@
|
|||||||
"version": "7.1.5",
|
"version": "7.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
@@ -1214,7 +1412,6 @@
|
|||||||
"version": "6.1.7",
|
"version": "6.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/delegates": {
|
"node_modules/delegates": {
|
||||||
@@ -1236,7 +1433,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/destroy": {
|
"node_modules/destroy": {
|
||||||
@@ -1303,7 +1499,6 @@
|
|||||||
"version": "3.21.0",
|
"version": "3.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
||||||
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
|
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
@@ -1320,7 +1515,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
||||||
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
@@ -1525,14 +1719,12 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-check": {
|
"node_modules/fast-check": {
|
||||||
"version": "3.23.2",
|
"version": "3.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||||
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
||||||
"devOptional": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -1703,7 +1895,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
||||||
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
|
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"citty": "^0.1.6",
|
"citty": "^0.1.6",
|
||||||
@@ -1905,7 +2096,6 @@
|
|||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
@@ -2254,7 +2444,6 @@
|
|||||||
"version": "1.6.7",
|
"version": "1.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||||
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nopt": {
|
"node_modules/nopt": {
|
||||||
@@ -2289,7 +2478,6 @@
|
|||||||
"version": "0.6.6",
|
"version": "0.6.6",
|
||||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz",
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz",
|
||||||
"integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==",
|
"integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"citty": "^0.2.2",
|
"citty": "^0.2.2",
|
||||||
@@ -2307,7 +2495,6 @@
|
|||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
|
||||||
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
@@ -2335,7 +2522,6 @@
|
|||||||
"version": "2.0.11",
|
"version": "2.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
@@ -2396,21 +2582,18 @@
|
|||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/perfect-debounce": {
|
"node_modules/perfect-debounce": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pkg-types": {
|
"node_modules/pkg-types": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
|
||||||
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
|
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"confbox": "^0.2.4",
|
"confbox": "^0.2.4",
|
||||||
@@ -2422,7 +2605,6 @@
|
|||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
|
||||||
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
|
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
|
||||||
"devOptional": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2467,7 +2649,6 @@
|
|||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||||
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
||||||
"devOptional": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -2523,7 +2704,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||||
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
|
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"defu": "^6.1.4",
|
"defu": "^6.1.4",
|
||||||
@@ -2555,7 +2735,6 @@
|
|||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14.18.0"
|
"node": ">= 14.18.0"
|
||||||
@@ -2956,7 +3135,6 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
|
||||||
"integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
|
"integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -2977,6 +3155,13 @@
|
|||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.22.3",
|
"version": "4.22.3",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"description": "EENE 인재성장팀 대시보드 - Backend API",
|
"description": "EENE 인재성장팀 대시보드 - Backend API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev:serve": "tsx watch src/index.ts",
|
||||||
|
"dev": "npm run db:sync && npm run db:seed-if-empty && npm run db:seed-team-if-empty && npm run dev:serve",
|
||||||
"db:sync": "prisma migrate deploy || prisma db push",
|
"db:sync": "prisma migrate deploy || prisma db push",
|
||||||
"build": "prisma generate && tsc",
|
"build": "prisma generate && tsc",
|
||||||
"start": "npm run db:sync && node dist/index.js",
|
"start": "npm run db:sync && node dist/index.js",
|
||||||
@@ -12,9 +13,17 @@
|
|||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"db:import-hr": "tsx scripts/import-hr-data.ts"
|
"db:rebuild": "tsx prisma/seed.ts",
|
||||||
|
"db:seed-if-empty": "tsx scripts/seed-if-empty.ts",
|
||||||
|
"db:seed-team-if-empty": "tsx scripts/seed-team-if-empty.ts",
|
||||||
|
"db:sync-team-photos": "tsx scripts/sync-team-photos.ts",
|
||||||
|
"db:cleanup-legacy-routine": "tsx scripts/cleanup-legacy-routine.ts",
|
||||||
|
"db:import-hr": "tsx scripts/import-hr-data.ts",
|
||||||
|
"db:migrate-sections": "tsx scripts/migrate-sections.ts",
|
||||||
|
"db:migrate-milestone-period-notes": "tsx scripts/migrate-milestone-period-notes.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ohah/hwpjs": "^0.1.0-rc.10",
|
||||||
"@prisma/client": "^6.0.0",
|
"@prisma/client": "^6.0.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import type { Priority, TaskStatus } from '@prisma/client';
|
import type { Priority, TaskStatus } from '@prisma/client';
|
||||||
|
import { getHrSeedPath, getUploadDir } from '../src/lib/projectPaths';
|
||||||
|
|
||||||
export interface HrProject {
|
export interface HrProject {
|
||||||
idx?: number;
|
idx?: number;
|
||||||
@@ -26,6 +27,21 @@ export interface HrProject {
|
|||||||
subPhases?: { name: string; status?: string; text?: string }[];
|
subPhases?: { name: string; status?: string; text?: string }[];
|
||||||
timelineItems?: { startDate?: string; endDate?: string; desc?: string }[];
|
timelineItems?: { startDate?: string; endDate?: string; desc?: string }[];
|
||||||
showOnDashboard?: boolean;
|
showOnDashboard?: boolean;
|
||||||
|
owners?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HrTeamEntry {
|
||||||
|
name: string;
|
||||||
|
photo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedHrTeamMember {
|
||||||
|
name: string;
|
||||||
|
rank: string | null;
|
||||||
|
role: string | null;
|
||||||
|
cell: string | null;
|
||||||
|
photoUrl: string | null;
|
||||||
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MappedTask {
|
export interface MappedTask {
|
||||||
@@ -41,7 +57,6 @@ export interface MappedTask {
|
|||||||
issueNote: string | null;
|
issueNote: string | null;
|
||||||
startDate: Date | null;
|
startDate: Date | null;
|
||||||
dueDate: Date | null;
|
dueDate: Date | null;
|
||||||
keywords: string | null;
|
|
||||||
showDate: boolean;
|
showDate: boolean;
|
||||||
showDescription: boolean;
|
showDescription: boolean;
|
||||||
showStatus: boolean;
|
showStatus: boolean;
|
||||||
@@ -59,8 +74,9 @@ export interface MappedTask {
|
|||||||
const SECTION_MAP: Record<string, string> = {
|
const SECTION_MAP: Record<string, string> = {
|
||||||
인사관리: '인사관리',
|
인사관리: '인사관리',
|
||||||
성장지원: '학습성장',
|
성장지원: '학습성장',
|
||||||
운영지원: '운영지원',
|
운영관리: '운영관리',
|
||||||
전산관리: '전산관리',
|
운영지원: '운영관리',
|
||||||
|
전산관리: '운영관리',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, TaskStatus> = {
|
const STATUS_MAP: Record<string, TaskStatus> = {
|
||||||
@@ -77,7 +93,7 @@ const PHASE_PROGRESS: Record<string, number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function defaultHrDataPath(): string {
|
export function defaultHrDataPath(): string {
|
||||||
return path.resolve(__dirname, '../../../HR_Dashboard/data.json');
|
return getHrSeedPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadHrProjects(filePath = defaultHrDataPath()): HrProject[] {
|
export function loadHrProjects(filePath = defaultHrDataPath()): HrProject[] {
|
||||||
@@ -86,6 +102,72 @@ export function loadHrProjects(filePath = defaultHrDataPath()): HrProject[] {
|
|||||||
return (data.PROJECTS ?? []).filter((p) => p.showOnDashboard !== false);
|
return (data.PROJECTS ?? []).filter((p) => p.showOnDashboard !== false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadHrTeam(filePath = defaultHrDataPath()): HrTeamEntry[] {
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const data = JSON.parse(raw) as { TEAM?: HrTeamEntry[] };
|
||||||
|
return data.TEAM ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "조태희 수석(팀장)" → name / rank / role */
|
||||||
|
export function parseHrTeamLabel(label: string, sortOrder: number): ParsedHrTeamMember {
|
||||||
|
const s = label.trim();
|
||||||
|
const withRole = s.match(/^(.+?)\s+(.+?)\((.+)\)$/);
|
||||||
|
if (withRole) {
|
||||||
|
const role = withRole[3].trim();
|
||||||
|
return {
|
||||||
|
name: withRole[1].trim(),
|
||||||
|
rank: withRole[2].trim(),
|
||||||
|
role,
|
||||||
|
cell: role === '팀장' ? null : 'HR',
|
||||||
|
photoUrl: null,
|
||||||
|
sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const plain = s.match(/^(.+?)\s+(.+)$/);
|
||||||
|
if (plain) {
|
||||||
|
return {
|
||||||
|
name: plain[1].trim(),
|
||||||
|
rank: plain[2].trim(),
|
||||||
|
role: null,
|
||||||
|
cell: 'HR',
|
||||||
|
photoUrl: null,
|
||||||
|
sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { name: s, rank: null, role: null, cell: 'HR', photoUrl: null, sortOrder };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapHrTeamMembers(filePath = defaultHrDataPath()): ParsedHrTeamMember[] {
|
||||||
|
return loadHrTeam(filePath).map((entry, i) => {
|
||||||
|
const parsed = parseHrTeamLabel(entry.name, i);
|
||||||
|
const photo = resolveTeamPhotoPath(entry.photo?.trim() || null);
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
photoUrl: photo,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** seed용 — 파일이 프로젝트 uploads/ 에 실제 있을 때만 경로 사용 */
|
||||||
|
export function resolveTeamPhotoPath(photo: string | null): string | null {
|
||||||
|
if (!photo?.trim()) return null;
|
||||||
|
const trimmed = photo.trim();
|
||||||
|
if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith('data:')) return null;
|
||||||
|
|
||||||
|
const uploadDir = getUploadDir();
|
||||||
|
const relative = trimmed.replace(/^\//, '').replace(/^uploads\//, '');
|
||||||
|
const abs = path.join(uploadDir, relative);
|
||||||
|
if (fs.existsSync(abs)) {
|
||||||
|
return trimmed.startsWith('/') ? trimmed : `/uploads/${relative}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PM·담당자 문자열 → team_members.name 매칭용 */
|
||||||
|
export function normalizePersonName(value: string): string {
|
||||||
|
return value.trim().replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
function parseDate(value?: string): Date | null {
|
function parseDate(value?: string): Date | null {
|
||||||
if (!value?.trim()) return null;
|
if (!value?.trim()) return null;
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
@@ -96,6 +178,14 @@ function mapSection(category: string): string {
|
|||||||
return SECTION_MAP[category] ?? category;
|
return SECTION_MAP[category] ?? category;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapBoardSection(p: HrProject): string {
|
||||||
|
const name = p.name.trim();
|
||||||
|
if (/회사생활|C\.E\.L|조직문화|복리후생|문화\s*진단|직원\s*소통/i.test(name)) {
|
||||||
|
return '조직문화';
|
||||||
|
}
|
||||||
|
return mapSection(p.category);
|
||||||
|
}
|
||||||
|
|
||||||
function mapStatus(status?: string, isRoutine = false): TaskStatus {
|
function mapStatus(status?: string, isRoutine = false): TaskStatus {
|
||||||
if (!status?.trim()) return isRoutine ? 'IN_PROGRESS' : 'TODO';
|
if (!status?.trim()) return isRoutine ? 'IN_PROGRESS' : 'TODO';
|
||||||
return STATUS_MAP[status] ?? 'IN_PROGRESS';
|
return STATUS_MAP[status] ?? 'IN_PROGRESS';
|
||||||
@@ -171,10 +261,9 @@ function buildMilestones(p: HrProject): MappedTask['milestones'] {
|
|||||||
return milestones;
|
return milestones;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDetailContent(p: HrProject): string | null {
|
/** @deprecated progressStatus는 milestone·periodEntries로 이관 — TaskDetail(피드백)에 넣지 않음 */
|
||||||
const content = p.progressStatus?.trim() || p.progressLog?.trim();
|
function buildDetailContent(_p: HrProject): string | null {
|
||||||
if (!content || content === '이슈사항' || content === '12') return null;
|
return null;
|
||||||
return content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTask {
|
export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTask {
|
||||||
@@ -188,14 +277,13 @@ export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTas
|
|||||||
status: mapStatus(p.status, isRoutine),
|
status: mapStatus(p.status, isRoutine),
|
||||||
priority: mapPriority(p.priority),
|
priority: mapPriority(p.priority),
|
||||||
quarter,
|
quarter,
|
||||||
category: mapSection(p.category),
|
category: mapBoardSection(p),
|
||||||
section: mapSection(p.category),
|
section: mapBoardSection(p),
|
||||||
taskType,
|
taskType,
|
||||||
progress: mapProgress(p.progress),
|
progress: mapProgress(p.progress),
|
||||||
issueNote: pickIssueNote(p),
|
issueNote: pickIssueNote(p),
|
||||||
startDate: parseDate(p.startDate),
|
startDate: parseDate(p.startDate),
|
||||||
dueDate: parseDate(p.endDate),
|
dueDate: parseDate(p.endDate),
|
||||||
keywords: p.keywords?.length ? p.keywords.join(', ') : null,
|
|
||||||
showDate: visible,
|
showDate: visible,
|
||||||
showDescription: visible,
|
showDescription: visible,
|
||||||
showStatus: visible,
|
showStatus: visible,
|
||||||
@@ -207,5 +295,7 @@ export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTas
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mapAllHrProjects(filePath?: string, quarter = '2026-Q2'): MappedTask[] {
|
export function mapAllHrProjects(filePath?: string, quarter = '2026-Q2'): MappedTask[] {
|
||||||
return loadHrProjects(filePath).map((p) => mapHrProjectToTask(p, quarter));
|
return loadHrProjects(filePath)
|
||||||
|
.filter((p) => p.priority !== '상시')
|
||||||
|
.map((p) => mapHrProjectToTask(p, quarter));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
ALTER TABLE "milestones" ADD COLUMN IF NOT EXISTS "pmMemberId" TEXT;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "milestone_assignees" (
|
||||||
|
"milestoneId" TEXT NOT NULL,
|
||||||
|
"memberId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "milestone_assignees_pkey" PRIMARY KEY ("milestoneId","memberId")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "milestone_assignees_memberId_idx" ON "milestone_assignees"("memberId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "milestones_pmMemberId_idx" ON "milestones"("pmMemberId");
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "milestones" ADD CONSTRAINT "milestones_pmMemberId_fkey"
|
||||||
|
FOREIGN KEY ("pmMemberId") REFERENCES "team_members"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "milestone_assignees" ADD CONSTRAINT "milestone_assignees_milestoneId_fkey"
|
||||||
|
FOREIGN KEY ("milestoneId") REFERENCES "milestones"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "milestone_assignees" ADD CONSTRAINT "milestone_assignees_memberId_fkey"
|
||||||
|
FOREIGN KEY ("memberId") REFERENCES "team_members"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 키워드 필드 제거 (데이터 삭제 후 컬럼 drop)
|
||||||
|
UPDATE "tasks" SET "keywords" = NULL WHERE "keywords" IS NOT NULL;
|
||||||
|
ALTER TABLE "tasks" DROP COLUMN IF EXISTS "keywords";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "tasks" ADD COLUMN "issueEntries" JSONB;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE IF NOT EXISTS "hub_configs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"config" JSONB NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "hub_configs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "milestones" ADD COLUMN IF NOT EXISTS "subtitle" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "milestones" ADD COLUMN "periodEntries" JSONB;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "tasks" ADD COLUMN "detailDescription" TEXT;
|
||||||
|
|
||||||
|
-- 기존 description(첫 줄=개요, 이후=상세) → 분리 (실행과제·프로젝트만)
|
||||||
|
UPDATE "tasks"
|
||||||
|
SET
|
||||||
|
"detailDescription" = CASE
|
||||||
|
WHEN position(E'\n' in "description") > 0
|
||||||
|
THEN NULLIF(trim(substring("description" from position(E'\n' in "description") + 1)), '')
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
"description" = NULLIF(trim(split_part("description", E'\n', 1)), '')
|
||||||
|
WHERE "description" IS NOT NULL
|
||||||
|
AND trim("description") != ''
|
||||||
|
AND ("taskType" = '실행과제' OR "taskType" = '프로젝트');
|
||||||
@@ -29,6 +29,31 @@ model User {
|
|||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 팀 인원 (조직도 마스터) ─────────────────────────────────
|
||||||
|
|
||||||
|
model TeamMember {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
rank String? // 직급 (수석연구원, 선임연구원 등)
|
||||||
|
role String? // 직책 (팀장, 셀장, 팀원 등)
|
||||||
|
cell String? // 셀 (HR, 총무, 리더 — 리더/빈값이면 상단 팀장 영역)
|
||||||
|
contact String?
|
||||||
|
photoUrl String?
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
pmTasks Task[] @relation("PmTasks")
|
||||||
|
taskAssignees TaskAssignee[]
|
||||||
|
milestonePmTasks Milestone[] @relation("MilestonePm")
|
||||||
|
milestoneAssignees MilestoneAssignee[]
|
||||||
|
|
||||||
|
@@index([cell])
|
||||||
|
@@index([isActive])
|
||||||
|
@@map("team_members")
|
||||||
|
}
|
||||||
|
|
||||||
enum Role {
|
enum Role {
|
||||||
ADMIN
|
ADMIN
|
||||||
MANAGER
|
MANAGER
|
||||||
@@ -41,6 +66,7 @@ model Task {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
|
detailDescription String?
|
||||||
status TaskStatus @default(TODO)
|
status TaskStatus @default(TODO)
|
||||||
priority Priority @default(MEDIUM)
|
priority Priority @default(MEDIUM)
|
||||||
quarter String // 예: "2026-Q2"
|
quarter String // 예: "2026-Q2"
|
||||||
@@ -49,7 +75,8 @@ model Task {
|
|||||||
tag String? // Growth | Policy | Performance | Culture | Asset | Space | Safety | Environment
|
tag String? // Growth | Policy | Performance | Culture | Asset | Space | Safety | Environment
|
||||||
taskType String? // 상시업무 | 프로젝트
|
taskType String? // 상시업무 | 프로젝트
|
||||||
progress Int @default(0)
|
progress Int @default(0)
|
||||||
issueNote String?
|
issueNote String?
|
||||||
|
issueEntries Json?
|
||||||
startDate DateTime?
|
startDate DateTime?
|
||||||
dueDate DateTime?
|
dueDate DateTime?
|
||||||
showDate Boolean @default(true)
|
showDate Boolean @default(true)
|
||||||
@@ -57,14 +84,16 @@ model Task {
|
|||||||
showStatus Boolean @default(true)
|
showStatus Boolean @default(true)
|
||||||
showIssue Boolean @default(true)
|
showIssue Boolean @default(true)
|
||||||
showProgress Boolean @default(true)
|
showProgress Boolean @default(true)
|
||||||
keywords String?
|
|
||||||
creatorId String
|
creatorId String
|
||||||
assigneeId String?
|
assigneeId String?
|
||||||
|
pmMemberId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
creator User @relation("CreatedTasks", fields: [creatorId], references: [id])
|
creator User @relation("CreatedTasks", fields: [creatorId], references: [id])
|
||||||
assignee User? @relation("AssignedTasks", fields: [assigneeId], references: [id])
|
assignee User? @relation("AssignedTasks", fields: [assigneeId], references: [id])
|
||||||
|
pmMember TeamMember? @relation("PmTasks", fields: [pmMemberId], references: [id])
|
||||||
|
taskAssignees TaskAssignee[]
|
||||||
details TaskDetail[]
|
details TaskDetail[]
|
||||||
kpiMetrics KpiMetric[]
|
kpiMetrics KpiMetric[]
|
||||||
files File[]
|
files File[]
|
||||||
@@ -73,10 +102,23 @@ model Task {
|
|||||||
@@index([quarter])
|
@@index([quarter])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([assigneeId])
|
@@index([assigneeId])
|
||||||
|
@@index([pmMemberId])
|
||||||
@@index([section])
|
@@index([section])
|
||||||
@@map("tasks")
|
@@map("tasks")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model TaskAssignee {
|
||||||
|
taskId String
|
||||||
|
memberId String
|
||||||
|
|
||||||
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
member TeamMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([taskId, memberId])
|
||||||
|
@@index([memberId])
|
||||||
|
@@map("task_assignees")
|
||||||
|
}
|
||||||
|
|
||||||
enum TaskStatus {
|
enum TaskStatus {
|
||||||
TODO
|
TODO
|
||||||
IN_PROGRESS
|
IN_PROGRESS
|
||||||
@@ -162,24 +204,42 @@ model Milestone {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
taskId String
|
taskId String
|
||||||
title String
|
title String
|
||||||
|
subtitle String?
|
||||||
description String?
|
description String?
|
||||||
startDate DateTime?
|
startDate DateTime?
|
||||||
dueDate DateTime?
|
dueDate DateTime?
|
||||||
|
periodEntries Json?
|
||||||
progress Int @default(0)
|
progress Int @default(0)
|
||||||
links String? // JSON: [{ "label": string, "url": string }]
|
links String? // JSON: [{ "label": string, "url": string }]
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
|
pmMemberId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
details TaskDetail[]
|
pmMember TeamMember? @relation("MilestonePm", fields: [pmMemberId], references: [id])
|
||||||
files File[]
|
milestoneAssignees MilestoneAssignee[]
|
||||||
|
details TaskDetail[]
|
||||||
|
files File[]
|
||||||
|
|
||||||
@@index([taskId])
|
@@index([taskId])
|
||||||
|
@@index([pmMemberId])
|
||||||
@@map("milestones")
|
@@map("milestones")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model MilestoneAssignee {
|
||||||
|
milestoneId String
|
||||||
|
memberId String
|
||||||
|
|
||||||
|
milestone Milestone @relation(fields: [milestoneId], references: [id], onDelete: Cascade)
|
||||||
|
member TeamMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([milestoneId, memberId])
|
||||||
|
@@index([memberId])
|
||||||
|
@@map("milestone_assignees")
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 컬럼 설정 ───────────────────────────────────────────────
|
// ─── 컬럼 설정 ───────────────────────────────────────────────
|
||||||
|
|
||||||
model ColumnConfig {
|
model ColumnConfig {
|
||||||
@@ -193,6 +253,16 @@ model ColumnConfig {
|
|||||||
@@map("column_configs")
|
@@map("column_configs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 허브 설정 (분기 중점 과제·일정·상시 라벨) ─────────────────
|
||||||
|
|
||||||
|
model HubConfig {
|
||||||
|
id String @id @default("default")
|
||||||
|
config Json
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("hub_configs")
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 감사 로그 ───────────────────────────────────────────────
|
// ─── 감사 로그 ───────────────────────────────────────────────
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
|
|||||||
@@ -1,67 +1,273 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { mapAllHrProjects } from './mapHrProjects';
|
|
||||||
|
import {
|
||||||
|
|
||||||
|
loadHrProjects,
|
||||||
|
|
||||||
|
mapAllHrProjects,
|
||||||
|
|
||||||
|
mapHrProjectToTask,
|
||||||
|
|
||||||
|
mapHrTeamMembers,
|
||||||
|
|
||||||
|
normalizePersonName,
|
||||||
|
|
||||||
|
} from './mapHrProjects';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const HUB_CONFIG = {
|
||||||
|
|
||||||
|
sloganTitle: '분기 중점 과제',
|
||||||
|
|
||||||
|
sloganLines: ['인사 · 육성 · 문화 · 총무', '개선과제', '정상 추진'],
|
||||||
|
|
||||||
|
scheduleTitle: '분기 주요 일정',
|
||||||
|
|
||||||
|
scheduleItems: [
|
||||||
|
|
||||||
|
{ id: '1', date: '2026-04-01', text: '상반기 채용·온보딩' },
|
||||||
|
|
||||||
|
{ id: '2', date: '2026-05-15', text: '조직문화 진단·리더십 교육' },
|
||||||
|
|
||||||
|
{ id: '3', date: '2026-06-20', text: '분기 성과 점검·평가' },
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
routineLabels: ['채용 운영', '학습 지원', '운영 지원', '자산·시설', '문서·행정'],
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('🌱 Seeding database...');
|
|
||||||
|
console.log('🌱 Seeding database from data/seed/hr-data.json ...');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const adminPw = await bcrypt.hash('admin1234!', 12);
|
const adminPw = await bcrypt.hash('admin1234!', 12);
|
||||||
|
|
||||||
const memberPw = await bcrypt.hash('member1234!', 12);
|
const memberPw = await bcrypt.hash('member1234!', 12);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const admin = await prisma.user.upsert({
|
const admin = await prisma.user.upsert({
|
||||||
|
|
||||||
where: { email: 'admin@eene.com' },
|
where: { email: 'admin@eene.com' },
|
||||||
|
|
||||||
update: {},
|
update: {},
|
||||||
|
|
||||||
create: { email: 'admin@eene.com', password: adminPw, name: '관리자', role: 'ADMIN', department: 'EENE' },
|
create: { email: 'admin@eene.com', password: adminPw, name: '관리자', role: 'ADMIN', department: 'EENE' },
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const member = await prisma.user.upsert({
|
const member = await prisma.user.upsert({
|
||||||
|
|
||||||
where: { email: 'member@eene.com' },
|
where: { email: 'member@eene.com' },
|
||||||
|
|
||||||
update: { name: '정성호' },
|
update: { name: '정성호' },
|
||||||
|
|
||||||
create: { email: 'member@eene.com', password: memberPw, name: '정성호', role: 'MEMBER', department: 'EENE' },
|
create: { email: 'member@eene.com', password: memberPw, name: '정성호', role: 'MEMBER', department: 'EENE' },
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
console.log('✅ Users ready');
|
console.log('✅ Users ready');
|
||||||
|
|
||||||
const mapped = mapAllHrProjects();
|
|
||||||
|
|
||||||
|
await prisma.milestoneAssignee.deleteMany({});
|
||||||
|
|
||||||
|
await prisma.taskAssignee.deleteMany({});
|
||||||
|
|
||||||
await prisma.file.deleteMany({});
|
await prisma.file.deleteMany({});
|
||||||
|
|
||||||
await prisma.taskDetail.deleteMany({});
|
await prisma.taskDetail.deleteMany({});
|
||||||
|
|
||||||
await prisma.milestone.deleteMany({});
|
await prisma.milestone.deleteMany({});
|
||||||
|
|
||||||
await prisma.kpiMetric.deleteMany({});
|
await prisma.kpiMetric.deleteMany({});
|
||||||
|
|
||||||
await prisma.task.deleteMany({});
|
await prisma.task.deleteMany({});
|
||||||
|
|
||||||
for (const t of mapped) {
|
await prisma.teamMember.deleteMany({});
|
||||||
const { milestones, detailContent, ...taskData } = t;
|
|
||||||
const task = await prisma.task.create({
|
|
||||||
|
|
||||||
|
const teamParsed = mapHrTeamMembers();
|
||||||
|
|
||||||
|
const memberIdByName = new Map<string, string>();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
for (const tm of teamParsed) {
|
||||||
|
|
||||||
|
const created = await prisma.teamMember.create({
|
||||||
|
|
||||||
data: {
|
data: {
|
||||||
...taskData,
|
|
||||||
creatorId: admin.id,
|
name: tm.name,
|
||||||
assigneeId: member.id,
|
|
||||||
|
rank: tm.rank,
|
||||||
|
|
||||||
|
role: tm.role,
|
||||||
|
|
||||||
|
cell: tm.cell,
|
||||||
|
|
||||||
|
photoUrl: tm.photoUrl,
|
||||||
|
|
||||||
|
sortOrder: tm.sortOrder,
|
||||||
|
|
||||||
|
isActive: true,
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [order, ms] of milestones.entries()) {
|
memberIdByName.set(normalizePersonName(tm.name), created.id);
|
||||||
await prisma.milestone.create({
|
|
||||||
data: { ...ms, taskId: task.id, order },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detailContent) {
|
|
||||||
await prisma.taskDetail.create({
|
|
||||||
data: {
|
|
||||||
taskId: task.id,
|
|
||||||
content: detailContent,
|
|
||||||
updatedBy: admin.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ Tasks created: ${mapped.length}개 (HR_Dashboard 데이터)`);
|
|
||||||
|
|
||||||
|
console.log(`✅ Team members: ${teamParsed.length}명 (hr-data.json TEAM)`);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const hrProjects = loadHrProjects();
|
||||||
|
|
||||||
|
let taskCount = 0;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
for (const hp of hrProjects) {
|
||||||
|
if (hp.priority === '상시') continue;
|
||||||
|
|
||||||
|
const t = mapHrProjectToTask(hp);
|
||||||
|
|
||||||
|
const { milestones, detailContent, ...taskData } = t;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const pmKey = hp.pm?.trim() ? normalizePersonName(hp.pm) : '';
|
||||||
|
|
||||||
|
const pmMemberId = pmKey ? memberIdByName.get(pmKey) ?? null : null;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const ownerIds = [...new Set(
|
||||||
|
|
||||||
|
(hp.owners ?? [])
|
||||||
|
|
||||||
|
.map((o) => normalizePersonName(o))
|
||||||
|
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
.map((key) => memberIdByName.get(key))
|
||||||
|
|
||||||
|
.filter((id): id is string => !!id),
|
||||||
|
|
||||||
|
)];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const task = await prisma.task.create({
|
||||||
|
|
||||||
|
data: {
|
||||||
|
|
||||||
|
...taskData,
|
||||||
|
|
||||||
|
creatorId: admin.id,
|
||||||
|
|
||||||
|
assigneeId: member.id,
|
||||||
|
|
||||||
|
pmMemberId,
|
||||||
|
|
||||||
|
...(ownerIds.length > 0
|
||||||
|
|
||||||
|
? { taskAssignees: { create: ownerIds.map((memberId) => ({ memberId })) } }
|
||||||
|
|
||||||
|
: {}),
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
for (const [order, ms] of milestones.entries()) {
|
||||||
|
|
||||||
|
await prisma.milestone.create({
|
||||||
|
|
||||||
|
data: { ...ms, taskId: task.id, order },
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (detailContent) {
|
||||||
|
|
||||||
|
await prisma.taskDetail.create({
|
||||||
|
|
||||||
|
data: {
|
||||||
|
|
||||||
|
taskId: task.id,
|
||||||
|
|
||||||
|
content: detailContent,
|
||||||
|
|
||||||
|
updatedBy: admin.id,
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
taskCount += 1;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
await prisma.hubConfig.upsert({
|
||||||
|
|
||||||
|
where: { id: 'default' },
|
||||||
|
|
||||||
|
update: { config: HUB_CONFIG },
|
||||||
|
|
||||||
|
create: { id: 'default', config: HUB_CONFIG },
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
console.log(`✅ Tasks: ${taskCount}개 (PROJECTS)`);
|
||||||
|
|
||||||
|
console.log('✅ Hub config reset (5 대분류 상시업무)');
|
||||||
|
|
||||||
console.log('🎉 Seeding complete!');
|
console.log('🎉 Seeding complete!');
|
||||||
|
|
||||||
|
console.log(' → 브라우저에서 Ctrl+F5 후, 필요 시 DevTools에서 localStorage 허브 키 삭제');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||||
|
|
||||||
|
|||||||
420
backend/scripts/_archive/deploy/sync-from-remote.ts
Normal file
420
backend/scripts/_archive/deploy/sync-from-remote.ts
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
/**
|
||||||
|
* 배포 서버(Render) 데이터 → 로컬 PostgreSQL 복사
|
||||||
|
* 사용: npm run db:sync-remote
|
||||||
|
* 환경변수 SOURCE_API_URL 로 원본 변경 가능
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { PrismaClient, type Priority, type TaskStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const SOURCE = (process.env.SOURCE_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, '');
|
||||||
|
const SECTIONS = ['인사관리', '학습성장', '운영관리'];
|
||||||
|
|
||||||
|
function normalizeSection(section: string | null | undefined): string | null {
|
||||||
|
if (!section) return null;
|
||||||
|
if (section === '전산관리' || section === '운영지원') return '운영관리';
|
||||||
|
if (section === '성장지원') return '학습성장';
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoteUser = { id: string; name: string; department?: string | null };
|
||||||
|
type RemoteTask = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
status: TaskStatus;
|
||||||
|
priority: Priority;
|
||||||
|
quarter: string;
|
||||||
|
category?: string | null;
|
||||||
|
section?: string | null;
|
||||||
|
tag?: string | null;
|
||||||
|
taskType?: string | null;
|
||||||
|
progress: number;
|
||||||
|
issueNote?: string | null;
|
||||||
|
startDate?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
showDate: boolean;
|
||||||
|
showDescription: boolean;
|
||||||
|
showStatus: boolean;
|
||||||
|
showIssue: boolean;
|
||||||
|
showProgress: boolean;
|
||||||
|
creatorId: string;
|
||||||
|
assigneeId?: string | null;
|
||||||
|
pmMemberId?: string | null;
|
||||||
|
creator?: RemoteUser;
|
||||||
|
assignee?: RemoteUser | null;
|
||||||
|
assigneeMembers?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rank?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
cell?: string | null;
|
||||||
|
contact?: string | null;
|
||||||
|
photoUrl?: string | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
}>;
|
||||||
|
pmMember?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rank?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
cell?: string | null;
|
||||||
|
contact?: string | null;
|
||||||
|
photoUrl?: string | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
} | null;
|
||||||
|
milestones?: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
startDate?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
progress: number;
|
||||||
|
links?: string | null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
order: number;
|
||||||
|
}>;
|
||||||
|
details?: Array<{
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
authorName?: string | null;
|
||||||
|
milestoneId?: string | null;
|
||||||
|
updatedBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
author?: RemoteUser;
|
||||||
|
}>;
|
||||||
|
kpiMetrics?: Array<{
|
||||||
|
id: string;
|
||||||
|
quarter: string;
|
||||||
|
target: number;
|
||||||
|
actual: number;
|
||||||
|
unit?: string | null;
|
||||||
|
}>;
|
||||||
|
files?: Array<{
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
originalName: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
mimetype: string;
|
||||||
|
size: number;
|
||||||
|
path: string;
|
||||||
|
milestoneId?: string | null;
|
||||||
|
uploadedBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchJson<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${SOURCE}${path}`);
|
||||||
|
if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`);
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureUser(remote?: RemoteUser | null, fallbackEmail?: string): Promise<string> {
|
||||||
|
const pw = await bcrypt.hash('imported!', 10);
|
||||||
|
const email = fallbackEmail || `${(remote?.name || 'user').replace(/\s/g, '')}@import.local`;
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { email },
|
||||||
|
update: { name: remote?.name || email },
|
||||||
|
create: {
|
||||||
|
email,
|
||||||
|
password: pw,
|
||||||
|
name: remote?.name || '사용자',
|
||||||
|
role: 'MEMBER',
|
||||||
|
department: remote?.department || 'EENE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTeamMember(data: {
|
||||||
|
name: string;
|
||||||
|
rank?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
cell?: string | null;
|
||||||
|
contact?: string | null;
|
||||||
|
photoUrl?: string | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
}): Promise<string> {
|
||||||
|
const existing = await prisma.teamMember.findFirst({
|
||||||
|
where: { name: data.name, cell: data.cell ?? null },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return prisma.teamMember
|
||||||
|
.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
rank: data.rank ?? existing.rank,
|
||||||
|
role: data.role ?? existing.role,
|
||||||
|
contact: data.contact ?? existing.contact,
|
||||||
|
photoUrl: data.photoUrl ?? existing.photoUrl,
|
||||||
|
sortOrder: data.sortOrder ?? existing.sortOrder,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((m) => m.id);
|
||||||
|
}
|
||||||
|
const created = await prisma.teamMember.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
rank: data.rank ?? null,
|
||||||
|
role: data.role ?? null,
|
||||||
|
cell: data.cell ?? null,
|
||||||
|
contact: data.contact ?? null,
|
||||||
|
photoUrl: data.photoUrl ?? null,
|
||||||
|
sortOrder: data.sortOrder ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearLocalData() {
|
||||||
|
await prisma.file.deleteMany({});
|
||||||
|
await prisma.taskDetail.deleteMany({});
|
||||||
|
await prisma.taskAssignee.deleteMany({});
|
||||||
|
await prisma.milestone.deleteMany({});
|
||||||
|
await prisma.kpiMetric.deleteMany({});
|
||||||
|
await prisma.task.deleteMany({});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncColumnConfigs() {
|
||||||
|
for (const key of SECTIONS) {
|
||||||
|
try {
|
||||||
|
const config = await fetchJson<{
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
titleEn?: string | null;
|
||||||
|
subtitle?: string | null;
|
||||||
|
cardOrder?: string | null;
|
||||||
|
}>(`/api/columns/${encodeURIComponent(key)}`);
|
||||||
|
|
||||||
|
await prisma.columnConfig.upsert({
|
||||||
|
where: { key },
|
||||||
|
create: {
|
||||||
|
key,
|
||||||
|
title: config.title,
|
||||||
|
titleEn: config.titleEn ?? '',
|
||||||
|
subtitle: config.subtitle ?? '',
|
||||||
|
cardOrder: config.cardOrder ?? null,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
title: config.title,
|
||||||
|
titleEn: config.titleEn ?? '',
|
||||||
|
subtitle: config.subtitle ?? '',
|
||||||
|
cardOrder: config.cardOrder ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(` ✓ column: ${key}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(` ⚠ column skip ${key}:`, (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncTeamMembersFromTasks(tasks: RemoteTask[]) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const task of tasks) {
|
||||||
|
const members = [
|
||||||
|
...(task.assigneeMembers ?? []),
|
||||||
|
...(task.pmMember ? [task.pmMember] : []),
|
||||||
|
];
|
||||||
|
for (const m of members) {
|
||||||
|
const key = `${m.name}|${m.cell ?? ''}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
await ensureTeamMember(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`📡 Source: ${SOURCE}`);
|
||||||
|
console.log('📥 Fetching remote tasks...');
|
||||||
|
|
||||||
|
const list = await fetchJson<RemoteTask[]>('/api/tasks');
|
||||||
|
console.log(` Found ${list.length} tasks`);
|
||||||
|
|
||||||
|
const fullTasks: RemoteTask[] = [];
|
||||||
|
for (const item of list) {
|
||||||
|
const full = await fetchJson<RemoteTask>(`/api/tasks/${item.id}`);
|
||||||
|
fullTasks.push(full);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🗑️ Clearing local task data...');
|
||||||
|
await clearLocalData();
|
||||||
|
|
||||||
|
const adminId = await ensureUser({ id: '', name: '관리자' }, 'admin@eene.com');
|
||||||
|
const userMap = new Map<string, string>();
|
||||||
|
userMap.set('admin', adminId);
|
||||||
|
|
||||||
|
console.log('👥 Syncing team members...');
|
||||||
|
try {
|
||||||
|
const remoteMembers = await fetchJson<
|
||||||
|
Array<{
|
||||||
|
name: string;
|
||||||
|
rank?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
cell?: string | null;
|
||||||
|
contact?: string | null;
|
||||||
|
photoUrl?: string | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
}>
|
||||||
|
>('/api/team-members');
|
||||||
|
for (const m of remoteMembers) await ensureTeamMember(m);
|
||||||
|
console.log(` ${remoteMembers.length} team members from API`);
|
||||||
|
} catch {
|
||||||
|
await syncTeamMembersFromTasks(fullTasks);
|
||||||
|
console.log(' team members inferred from tasks');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📋 Importing tasks...');
|
||||||
|
for (const remote of fullTasks) {
|
||||||
|
let creatorId = userMap.get(remote.creatorId);
|
||||||
|
if (!creatorId) {
|
||||||
|
creatorId = await ensureUser(remote.creator, `creator-${remote.creatorId}@import.local`);
|
||||||
|
userMap.set(remote.creatorId, creatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let assigneeId: string | null = null;
|
||||||
|
if (remote.assignee) {
|
||||||
|
let mapped = userMap.get(remote.assignee.id);
|
||||||
|
if (!mapped) {
|
||||||
|
mapped = await ensureUser(remote.assignee, `assignee-${remote.assignee.id}@import.local`);
|
||||||
|
userMap.set(remote.assignee.id, mapped);
|
||||||
|
}
|
||||||
|
assigneeId = mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pmMemberId: string | null = null;
|
||||||
|
if (remote.pmMember) {
|
||||||
|
pmMemberId = await ensureTeamMember(remote.pmMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
title: remote.title,
|
||||||
|
description: remote.description ?? null,
|
||||||
|
status: remote.status,
|
||||||
|
priority: remote.priority,
|
||||||
|
quarter: remote.quarter,
|
||||||
|
category: remote.category ?? null,
|
||||||
|
section: normalizeSection(remote.section),
|
||||||
|
tag: remote.tag ?? null,
|
||||||
|
taskType: remote.taskType ?? null,
|
||||||
|
progress: remote.progress ?? 0,
|
||||||
|
issueNote: remote.issueNote ?? null,
|
||||||
|
startDate: remote.startDate ? new Date(remote.startDate) : null,
|
||||||
|
dueDate: remote.dueDate ? new Date(remote.dueDate) : null,
|
||||||
|
showDate: remote.showDate,
|
||||||
|
showDescription: remote.showDescription,
|
||||||
|
showStatus: remote.showStatus,
|
||||||
|
showIssue: remote.showIssue,
|
||||||
|
showProgress: remote.showProgress,
|
||||||
|
creatorId,
|
||||||
|
assigneeId,
|
||||||
|
pmMemberId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const milestoneIdMap = new Map<string, string>();
|
||||||
|
for (const ms of remote.milestones ?? []) {
|
||||||
|
const created = await prisma.milestone.create({
|
||||||
|
data: {
|
||||||
|
taskId: task.id,
|
||||||
|
title: ms.title,
|
||||||
|
description: ms.description ?? null,
|
||||||
|
startDate: ms.startDate ? new Date(ms.startDate) : null,
|
||||||
|
dueDate: ms.dueDate ? new Date(ms.dueDate) : null,
|
||||||
|
progress: ms.progress ?? 0,
|
||||||
|
links: ms.links ?? null,
|
||||||
|
completedAt: ms.completedAt ? new Date(ms.completedAt) : null,
|
||||||
|
order: ms.order ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
milestoneIdMap.set(ms.id, created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const d of remote.details ?? []) {
|
||||||
|
let authorId = userMap.get(d.updatedBy);
|
||||||
|
if (!authorId) {
|
||||||
|
authorId = await ensureUser(d.author, `author-${d.updatedBy}@import.local`);
|
||||||
|
userMap.set(d.updatedBy, authorId);
|
||||||
|
}
|
||||||
|
await prisma.taskDetail.create({
|
||||||
|
data: {
|
||||||
|
taskId: task.id,
|
||||||
|
milestoneId: d.milestoneId ? milestoneIdMap.get(d.milestoneId) ?? null : null,
|
||||||
|
content: d.content,
|
||||||
|
authorName: d.authorName ?? null,
|
||||||
|
updatedBy: authorId,
|
||||||
|
createdAt: new Date(d.createdAt),
|
||||||
|
updatedAt: new Date(d.updatedAt),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of remote.kpiMetrics ?? []) {
|
||||||
|
await prisma.kpiMetric.create({
|
||||||
|
data: {
|
||||||
|
taskId: task.id,
|
||||||
|
quarter: k.quarter,
|
||||||
|
target: k.target,
|
||||||
|
actual: k.actual,
|
||||||
|
unit: k.unit ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const assigneeMemberIds: string[] = [];
|
||||||
|
for (const m of remote.assigneeMembers ?? []) {
|
||||||
|
assigneeMemberIds.push(await ensureTeamMember(m));
|
||||||
|
}
|
||||||
|
if (assigneeMemberIds.length > 0) {
|
||||||
|
await prisma.taskAssignee.createMany({
|
||||||
|
data: [...new Set(assigneeMemberIds)].map((memberId) => ({ taskId: task.id, memberId })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const f of remote.files ?? []) {
|
||||||
|
let uploaderId = userMap.get(f.uploadedBy);
|
||||||
|
if (!uploaderId) {
|
||||||
|
uploaderId = creatorId;
|
||||||
|
userMap.set(f.uploadedBy, uploaderId);
|
||||||
|
}
|
||||||
|
await prisma.file.create({
|
||||||
|
data: {
|
||||||
|
taskId: task.id,
|
||||||
|
milestoneId: f.milestoneId ? milestoneIdMap.get(f.milestoneId) ?? null : null,
|
||||||
|
filename: f.filename,
|
||||||
|
originalName: f.originalName,
|
||||||
|
displayName: f.displayName ?? null,
|
||||||
|
sortOrder: f.sortOrder ?? 0,
|
||||||
|
mimetype: f.mimetype,
|
||||||
|
size: f.size,
|
||||||
|
path: f.path,
|
||||||
|
uploadedBy: uploaderId,
|
||||||
|
createdAt: new Date(f.createdAt),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✓ ${remote.title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📐 Syncing column order...');
|
||||||
|
await syncColumnConfigs();
|
||||||
|
|
||||||
|
const count = await prisma.task.count();
|
||||||
|
console.log(`\n✅ Done! Local DB now has ${count} tasks (from ${SOURCE})`);
|
||||||
|
console.log(' Refresh http://localhost:3000');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('❌ Sync failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
300
backend/scripts/_archive/deploy/sync-to-remote.ts
Normal file
300
backend/scripts/_archive/deploy/sync-to-remote.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* 로컬 PostgreSQL → 배포 서버(Render API) 데이터 업로드
|
||||||
|
* 사용: npm run db:push-remote
|
||||||
|
* 환경변수 TARGET_API_URL 로 대상 변경 가능
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const TARGET = (process.env.TARGET_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, '');
|
||||||
|
const SECTIONS = ['인사관리', '학습성장', '운영관리'];
|
||||||
|
const PHOTOS_ONLY = process.argv.includes('--photos-only');
|
||||||
|
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || path.join(__dirname, '../../uploads'));
|
||||||
|
const TEAM_DIR = path.join(UPLOAD_DIR, 'team');
|
||||||
|
|
||||||
|
function resolveLocalPhotoPath(photoUrl: string | null | undefined): string | null {
|
||||||
|
if (!photoUrl || /^https?:\/\//i.test(photoUrl) || photoUrl.startsWith('data:')) return null;
|
||||||
|
const filename = photoUrl.replace(/^\/uploads\/team\//, '').replace(/^uploads\/team\//, '');
|
||||||
|
if (!filename || filename.includes('..')) return null;
|
||||||
|
const filePath = path.join(TEAM_DIR, filename);
|
||||||
|
return fs.existsSync(filePath) ? filePath : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimeForExt(ext: string): string {
|
||||||
|
if (ext === '.png') return 'image/png';
|
||||||
|
if (ext === '.webp') return 'image/webp';
|
||||||
|
if (ext === '.gif') return 'image/gif';
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadPhotoToRemote(filePath: string): Promise<string> {
|
||||||
|
const buf = fs.readFileSync(filePath);
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('photo', new Blob([buf], { type: mimeForExt(ext) }), path.basename(filePath));
|
||||||
|
|
||||||
|
const res = await fetch(`${TARGET}/api/team-members/photo`, { method: 'POST', body: form });
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`photo upload failed: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { url: string };
|
||||||
|
return data.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${TARGET}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`${method} ${path} → ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberKey(name: string, cell: string | null) {
|
||||||
|
return `${name}|${cell ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureRemoteApiReady() {
|
||||||
|
const res = await fetch(`${TARGET}/api/team-members`);
|
||||||
|
if (res.status === 404) {
|
||||||
|
throw new Error(
|
||||||
|
'배포 서버에 team-members API가 없습니다. 코드 배포(Render) 완료 후 다시 실행하세요.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`team-members check failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncTeamMembers(): Promise<Map<string, string>> {
|
||||||
|
const locals = await prisma.teamMember.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
type RemoteMember = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cell: string | null;
|
||||||
|
rank?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
contact?: string | null;
|
||||||
|
photoUrl?: string | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let remotes: RemoteMember[] = [];
|
||||||
|
try {
|
||||||
|
remotes = await api<RemoteMember[]>('GET', '/api/team-members?all=1');
|
||||||
|
} catch {
|
||||||
|
remotes = await api<RemoteMember[]>('GET', '/api/team-members');
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteByKey = new Map(remotes.map((m) => [memberKey(m.name, m.cell), m]));
|
||||||
|
const idMap = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const local of locals) {
|
||||||
|
const key = memberKey(local.name, local.cell);
|
||||||
|
let photoUrl = local.photoUrl;
|
||||||
|
|
||||||
|
const localPhotoPath = resolveLocalPhotoPath(local.photoUrl);
|
||||||
|
if (localPhotoPath) {
|
||||||
|
try {
|
||||||
|
photoUrl = await uploadPhotoToRemote(localPhotoPath);
|
||||||
|
console.log(` 📷 ${local.name} — ${path.basename(localPhotoPath)}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(` ⚠ ${local.name} photo skip:`, (err as Error).message);
|
||||||
|
}
|
||||||
|
} else if (local.photoUrl) {
|
||||||
|
console.warn(` ⚠ ${local.name} — local file not found: ${local.photoUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: local.name,
|
||||||
|
rank: local.rank,
|
||||||
|
role: local.role,
|
||||||
|
cell: local.cell,
|
||||||
|
contact: local.contact,
|
||||||
|
photoUrl,
|
||||||
|
sortOrder: local.sortOrder,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = remoteByKey.get(key);
|
||||||
|
if (existing) {
|
||||||
|
await api('PATCH', `/api/team-members/${existing.id}`, payload);
|
||||||
|
idMap.set(local.id, existing.id);
|
||||||
|
console.log(` ✓ team ${local.name} (updated)`);
|
||||||
|
} else {
|
||||||
|
const created = await api<RemoteMember>('POST', '/api/team-members', payload);
|
||||||
|
idMap.set(local.id, created.id);
|
||||||
|
remoteByKey.set(key, created);
|
||||||
|
console.log(` ✓ team ${local.name} (created)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return idMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearRemoteTasks() {
|
||||||
|
const remoteTasks = await api<Array<{ id: string }>>('GET', '/api/tasks');
|
||||||
|
for (const t of remoteTasks) {
|
||||||
|
await api('DELETE', `/api/tasks/${t.id}`);
|
||||||
|
}
|
||||||
|
console.log(` removed ${remoteTasks.length} remote tasks`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncTasks(memberIdMap: Map<string, string>) {
|
||||||
|
const tasks = await prisma.task.findMany({
|
||||||
|
include: {
|
||||||
|
milestones: { orderBy: { order: 'asc' } },
|
||||||
|
details: { orderBy: { createdAt: 'asc' } },
|
||||||
|
kpiMetrics: true,
|
||||||
|
taskAssignees: true,
|
||||||
|
pmMember: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
const assigneeMemberIds = task.taskAssignees
|
||||||
|
.map((ta) => memberIdMap.get(ta.memberId))
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
|
const pmMemberId = task.pmMemberId ? memberIdMap.get(task.pmMemberId) ?? null : null;
|
||||||
|
|
||||||
|
const created = await api<{ id: string }>('POST', '/api/tasks', {
|
||||||
|
title: task.title,
|
||||||
|
description: task.description,
|
||||||
|
status: task.status,
|
||||||
|
priority: task.priority,
|
||||||
|
quarter: task.quarter,
|
||||||
|
category: task.category,
|
||||||
|
section: task.section,
|
||||||
|
tag: task.tag,
|
||||||
|
taskType: task.taskType,
|
||||||
|
progress: task.progress,
|
||||||
|
issueNote: task.issueNote,
|
||||||
|
startDate: task.startDate?.toISOString() ?? null,
|
||||||
|
dueDate: task.dueDate?.toISOString() ?? null,
|
||||||
|
showDate: task.showDate,
|
||||||
|
showDescription: task.showDescription,
|
||||||
|
showStatus: task.showStatus,
|
||||||
|
showIssue: task.showIssue,
|
||||||
|
showProgress: task.showProgress,
|
||||||
|
pmMemberId,
|
||||||
|
assigneeMemberIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const milestoneIdMap = new Map<string, string>();
|
||||||
|
for (const ms of task.milestones) {
|
||||||
|
const remoteMs = await api<{ id: string }>('POST', `/api/milestones/${created.id}`, {
|
||||||
|
title: ms.title,
|
||||||
|
description: ms.description,
|
||||||
|
startDate: ms.startDate?.toISOString() ?? null,
|
||||||
|
dueDate: ms.dueDate?.toISOString() ?? null,
|
||||||
|
progress: ms.progress,
|
||||||
|
links: ms.links,
|
||||||
|
});
|
||||||
|
milestoneIdMap.set(ms.id, remoteMs.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const d of task.details) {
|
||||||
|
await api('POST', `/api/details/${created.id}`, {
|
||||||
|
content: d.content,
|
||||||
|
authorName: d.authorName,
|
||||||
|
milestoneId: d.milestoneId ? milestoneIdMap.get(d.milestoneId) ?? null : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of task.kpiMetrics) {
|
||||||
|
await api('POST', '/api/kpi', {
|
||||||
|
taskId: created.id,
|
||||||
|
quarter: k.quarter,
|
||||||
|
target: k.target,
|
||||||
|
actual: k.actual,
|
||||||
|
unit: k.unit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✓ task ${task.title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncColumnConfigs() {
|
||||||
|
const configs = await prisma.columnConfig.findMany();
|
||||||
|
for (const config of configs) {
|
||||||
|
await api('PATCH', `/api/columns/${encodeURIComponent(config.key)}`, {
|
||||||
|
title: config.title,
|
||||||
|
titleEn: config.titleEn,
|
||||||
|
subtitle: config.subtitle,
|
||||||
|
cardOrder: config.cardOrder,
|
||||||
|
});
|
||||||
|
console.log(` ✓ column ${config.key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of SECTIONS) {
|
||||||
|
if (!configs.some((c) => c.key === key)) {
|
||||||
|
const local = await prisma.columnConfig.findUnique({ where: { key } });
|
||||||
|
if (local) continue;
|
||||||
|
try {
|
||||||
|
await api('GET', `/api/columns/${encodeURIComponent(key)}`);
|
||||||
|
} catch {
|
||||||
|
/* ensure exists on remote */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`📤 Target: ${TARGET}`);
|
||||||
|
console.log('🔍 Checking remote API...');
|
||||||
|
await ensureRemoteApiReady();
|
||||||
|
|
||||||
|
const localTasks = await prisma.task.count();
|
||||||
|
const localMembers = await prisma.teamMember.count({ where: { isActive: true } });
|
||||||
|
console.log(` Local: ${localTasks} tasks, ${localMembers} team members`);
|
||||||
|
|
||||||
|
if (!PHOTOS_ONLY && localTasks === 0) {
|
||||||
|
throw new Error('로컬 DB에 업무가 없습니다. 먼저 데이터가져오기.bat 또는 작업을 진행하세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`👥 Uploading team members${PHOTOS_ONLY ? ' + photos' : ''}...`);
|
||||||
|
console.log(` Local photos dir: ${TEAM_DIR}`);
|
||||||
|
const memberIdMap = await syncTeamMembers();
|
||||||
|
|
||||||
|
if (PHOTOS_ONLY) {
|
||||||
|
console.log('\n✅ Photos sync complete!');
|
||||||
|
console.log(' Site: https://eene-dashboard.vercel.app/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🗑️ Clearing remote tasks...');
|
||||||
|
await clearRemoteTasks();
|
||||||
|
|
||||||
|
console.log('📋 Uploading tasks...');
|
||||||
|
await syncTasks(memberIdMap);
|
||||||
|
|
||||||
|
console.log('📐 Uploading column order...');
|
||||||
|
await syncColumnConfigs();
|
||||||
|
|
||||||
|
const remoteTasks = await api<unknown[]>('GET', '/api/tasks');
|
||||||
|
console.log(`\n✅ Done! Remote now has ${remoteTasks.length} tasks`);
|
||||||
|
console.log(` Site: https://eene-dashboard.vercel.app/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('❌ Push failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
75
backend/scripts/cleanup-legacy-routine.ts
Normal file
75
backend/scripts/cleanup-legacy-routine.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { getProjectRoot } from '../src/lib/projectPaths';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/** hr-data legacy 상시업무 — 허브 대분류 셸로 대체, 시드·DB에서 제거 */
|
||||||
|
const LEGACY_ROUTINE_TITLES = [
|
||||||
|
'H/W, S/W',
|
||||||
|
'시설관리',
|
||||||
|
'배움터',
|
||||||
|
'인재채용',
|
||||||
|
'학습 지원',
|
||||||
|
'채용 운영',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function deleteLegacyRoutineTasks() {
|
||||||
|
for (const title of LEGACY_ROUTINE_TITLES) {
|
||||||
|
const task = await prisma.task.findFirst({
|
||||||
|
where: { title, taskType: { in: ['기반업무', '상시업무'] } },
|
||||||
|
});
|
||||||
|
if (!task) {
|
||||||
|
console.log(` skip (not found): ${title}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await prisma.task.delete({ where: { id: task.id } });
|
||||||
|
console.log(` 🗑 deleted: ${title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = await prisma.task.count({
|
||||||
|
where: { taskType: { in: ['기반업무', '상시업무'] } },
|
||||||
|
});
|
||||||
|
if (remaining > 0) {
|
||||||
|
const extras = await prisma.task.findMany({
|
||||||
|
where: { taskType: { in: ['기반업무', '상시업무'] } },
|
||||||
|
select: { title: true },
|
||||||
|
});
|
||||||
|
for (const t of extras) {
|
||||||
|
await prisma.task.deleteMany({ where: { title: t.title, taskType: { in: ['기반업무', '상시업무'] } } });
|
||||||
|
console.log(` 🗑 deleted (extra routine): ${t.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchHrData() {
|
||||||
|
const seedPath = path.join(getProjectRoot(), 'data', 'seed', 'hr-data.json');
|
||||||
|
const data = JSON.parse(fs.readFileSync(seedPath, 'utf-8')) as {
|
||||||
|
PROJECTS: { name: string; priority?: string; [key: string]: unknown }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const before = data.PROJECTS.length;
|
||||||
|
data.PROJECTS = data.PROJECTS.filter((p) => p.priority !== '상시');
|
||||||
|
|
||||||
|
fs.writeFileSync(seedPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
console.log(` ✓ hr-data.json: removed ${before - data.PROJECTS.length} legacy 상시 entries (${data.PROJECTS.length} projects left)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🧹 Delete all legacy routine tasks from DB ...');
|
||||||
|
await deleteLegacyRoutineTasks();
|
||||||
|
|
||||||
|
console.log('📝 Remove priority:상시 from hr-data.json ...');
|
||||||
|
patchHrData();
|
||||||
|
|
||||||
|
console.log('Done. 상시업무는 허브에서 대분류 클릭 시 새 셸로 생성됩니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
54
backend/scripts/cleanup-legacy-task-details.ts
Normal file
54
backend/scripts/cleanup-legacy-task-details.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* HR import 시 TaskDetail(피드백)에 잘못 들어간 progressStatus 레거시 삭제
|
||||||
|
* — milestoneId 없고 authorName 없는 행 (seed 자동 생성분)
|
||||||
|
*
|
||||||
|
* npx tsx scripts/cleanup-legacy-task-details.ts
|
||||||
|
* npx tsx scripts/cleanup-legacy-task-details.ts --dry-run
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const dryRun = process.argv.includes('--dry-run');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const legacy = await prisma.taskDetail.findMany({
|
||||||
|
where: {
|
||||||
|
milestoneId: null,
|
||||||
|
OR: [{ authorName: null }, { authorName: '' }],
|
||||||
|
},
|
||||||
|
include: { task: { select: { title: true } } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (legacy.length === 0) {
|
||||||
|
console.log('✅ 삭제할 레거시 피드백 없음');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`레거시 TaskDetail ${legacy.length}건 (일정 미연결 · 작성자 없음):`);
|
||||||
|
for (const row of legacy) {
|
||||||
|
const preview = row.content.replace(/\s+/g, ' ').slice(0, 72);
|
||||||
|
console.log(` · [${row.task.title}] ${preview}${row.content.length > 72 ? '…' : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log('\n(dry-run — 삭제하지 않음. 적용: npx tsx scripts/cleanup-legacy-task-details.ts)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.taskDetail.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: { in: legacy.map((r) => r.id) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n✅ ${result.count}건 삭제 완료`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -102,7 +102,7 @@ async function importViaApi(adminId: string, memberId: string) {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const tasks = mapAllHrProjects();
|
const tasks = mapAllHrProjects();
|
||||||
console.log(`📦 HR_Dashboard → ${tasks.length} tasks mapped`);
|
console.log(`📦 data/seed/hr-data.json → ${tasks.length} tasks mapped`);
|
||||||
|
|
||||||
let adminId: string;
|
let adminId: string;
|
||||||
let memberId: string;
|
let memberId: string;
|
||||||
|
|||||||
46
backend/scripts/migrate-board-sections.ts
Normal file
46
backend/scripts/migrate-board-sections.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 4분면 보드 section 정렬 — 조직문화(EX) 프로젝트 재배치
|
||||||
|
* npx tsx scripts/migrate-board-sections.ts
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const EX_TITLE = /회사생활|C\.E\.L|조직문화|복리후생|문화\s*진단|직원\s*소통/i;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const tasks = await prisma.task.findMany({
|
||||||
|
select: { id: true, title: true, section: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let moved = 0;
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (task.section === '조직문화') continue;
|
||||||
|
if (!EX_TITLE.test(task.title.trim())) continue;
|
||||||
|
await prisma.task.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: { section: '조직문화', category: '조직문화' },
|
||||||
|
});
|
||||||
|
moved += 1;
|
||||||
|
console.log(` → 조직문화: ${task.title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const col = await prisma.columnConfig.findUnique({ where: { key: '운영관리' } });
|
||||||
|
if (col && (col.title === '운영관리' || col.title === '운영관리 부문' || col.titleEn === 'Operations')) {
|
||||||
|
await prisma.columnConfig.update({
|
||||||
|
where: { key: '운영관리' },
|
||||||
|
data: { title: '총무관리', titleEn: 'GA' },
|
||||||
|
});
|
||||||
|
console.log(' → column 운영관리 title → 총무관리');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ migrate-board-sections complete (${moved} tasks moved)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
59
backend/scripts/migrate-milestone-period-notes.ts
Normal file
59
backend/scripts/migrate-milestone-period-notes.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { Prisma, PrismaClient } from '@prisma/client';
|
||||||
|
import { migrateDescriptionToPeriodEntries } from '../src/lib/milestonePeriods';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const milestones = await prisma.milestone.findMany({
|
||||||
|
where: {
|
||||||
|
description: { not: null },
|
||||||
|
NOT: { description: '' },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
startDate: true,
|
||||||
|
dueDate: true,
|
||||||
|
periodEntries: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let migrated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const m of milestones) {
|
||||||
|
const { periodEntries, migrated: didMigrate } = migrateDescriptionToPeriodEntries({
|
||||||
|
id: m.id,
|
||||||
|
periodEntries: m.periodEntries,
|
||||||
|
startDate: m.startDate,
|
||||||
|
dueDate: m.dueDate,
|
||||||
|
description: m.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!didMigrate || !periodEntries) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.milestone.update({
|
||||||
|
where: { id: m.id },
|
||||||
|
data: {
|
||||||
|
periodEntries: periodEntries as Prisma.InputJsonValue,
|
||||||
|
description: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
migrated += 1;
|
||||||
|
console.log(` ✓ ${m.title} → 기간1 note (${periodEntries.length}건)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDone. migrated=${migrated}, skipped=${skipped}, scanned=${milestones.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
66
backend/scripts/migrate-sections.ts
Normal file
66
backend/scripts/migrate-sections.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* 전산관리·운영지원 → 운영관리 부문 통합 (1회 실행)
|
||||||
|
* 사용: npm run db:migrate-sections
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const MERGE_INTO = '운영관리';
|
||||||
|
const FROM = ['전산관리', '운영지원'] as const;
|
||||||
|
|
||||||
|
async function mergeCardOrder(intoKey: string, fromKeys: readonly string[]) {
|
||||||
|
const target = await prisma.columnConfig.findUnique({ where: { key: intoKey } });
|
||||||
|
const orders: string[] = [];
|
||||||
|
if (target?.cardOrder) {
|
||||||
|
try {
|
||||||
|
orders.push(...JSON.parse(target.cardOrder));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const from of fromKeys) {
|
||||||
|
const src = await prisma.columnConfig.findUnique({ where: { key: from } });
|
||||||
|
if (!src?.cardOrder) continue;
|
||||||
|
try {
|
||||||
|
const ids = JSON.parse(src.cardOrder) as string[];
|
||||||
|
for (const id of ids) {
|
||||||
|
if (!orders.includes(id)) orders.push(id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (orders.length === 0 && !target) return;
|
||||||
|
await prisma.columnConfig.upsert({
|
||||||
|
where: { key: intoKey },
|
||||||
|
update: { cardOrder: JSON.stringify(orders) },
|
||||||
|
create: {
|
||||||
|
key: intoKey,
|
||||||
|
title: '운영관리',
|
||||||
|
titleEn: 'GA',
|
||||||
|
subtitle: '',
|
||||||
|
cardOrder: JSON.stringify(orders),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
for (const from of FROM) {
|
||||||
|
const { count } = await prisma.task.updateMany({
|
||||||
|
where: { section: from },
|
||||||
|
data: { section: MERGE_INTO },
|
||||||
|
});
|
||||||
|
if (count > 0) console.log(` ✓ ${from} → ${MERGE_INTO}: ${count} tasks`);
|
||||||
|
}
|
||||||
|
await mergeCardOrder(MERGE_INTO, FROM);
|
||||||
|
console.log('✅ Section migration done.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
77
backend/scripts/normalize-task-sections.ts
Normal file
77
backend/scripts/normalize-task-sections.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* task.section 레거시 값 정규화 + 조직문화(EX) 재배치
|
||||||
|
* npx tsx scripts/normalize-task-sections.ts
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const EX_TITLE = /회사생활|C\.E\.L|조직문화|복리후생|문화\s*진단|직원\s*소통/i;
|
||||||
|
|
||||||
|
const SECTION_MAP: Record<string, string> = {
|
||||||
|
성장지원: '학습성장',
|
||||||
|
HR: '인사관리',
|
||||||
|
운영지원: '운영관리',
|
||||||
|
전산관리: '운영관리',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const tasks = await prisma.task.findMany({
|
||||||
|
select: { id: true, title: true, section: true, category: true, taskType: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let renamed = 0;
|
||||||
|
let exMoved = 0;
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
const section = task.section?.trim() ?? '';
|
||||||
|
let nextSection = SECTION_MAP[section] ?? section;
|
||||||
|
|
||||||
|
if (nextSection !== '조직문화' && EX_TITLE.test(task.title.trim())) {
|
||||||
|
nextSection = '조직문화';
|
||||||
|
exMoved += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: { section?: string; category?: string | null } = {};
|
||||||
|
if (nextSection && nextSection !== section) {
|
||||||
|
patch.section = nextSection;
|
||||||
|
renamed += 1;
|
||||||
|
}
|
||||||
|
if (nextSection === '조직문화' && task.category !== '조직문화') {
|
||||||
|
patch.category = '조직문화';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(patch).length > 0) {
|
||||||
|
await prisma.task.update({ where: { id: task.id }, data: patch });
|
||||||
|
console.log(` section: ${section || '(empty)'} → ${nextSection} | ${task.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const col = await prisma.columnConfig.findUnique({ where: { key: '운영관리' } });
|
||||||
|
if (col && (col.title === '운영관리' || col.title === '운영관리 부문' || col.titleEn === 'Operations')) {
|
||||||
|
await prisma.columnConfig.update({
|
||||||
|
where: { key: '운영관리' },
|
||||||
|
data: { title: '총무관리', titleEn: 'GA' },
|
||||||
|
});
|
||||||
|
console.log(' columnConfig 운영관리 → 총무관리');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hrd = await prisma.columnConfig.findUnique({ where: { key: '학습성장' } });
|
||||||
|
if (hrd && (hrd.title === '학습성장' || hrd.title === '성장지원' || hrd.titleEn === 'Learning & Growth')) {
|
||||||
|
await prisma.columnConfig.update({
|
||||||
|
where: { key: '학습성장' },
|
||||||
|
data: { title: '인재육성', titleEn: 'HRD' },
|
||||||
|
});
|
||||||
|
console.log(' columnConfig 학습성장 → 인재육성');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ normalize-task-sections complete (${renamed} renamed, ${exMoved} → 조직문화)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
28
backend/scripts/seed-if-empty.ts
Normal file
28
backend/scripts/seed-if-empty.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* 로컬 DB가 비어 있을 때만 seed 실행 (최초 1회용)
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const users = await prisma.user.count();
|
||||||
|
if (users > 0) {
|
||||||
|
console.log('✓ Local DB has data — skip initial seed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📦 Empty local DB — loading initial sample data...');
|
||||||
|
const backendRoot = path.resolve(__dirname, '..');
|
||||||
|
execSync('tsx prisma/seed.ts', { stdio: 'inherit', cwd: backendRoot });
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
45
backend/scripts/seed-team-if-empty.ts
Normal file
45
backend/scripts/seed-team-if-empty.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* 팀원이 0명일 때만 hr-data.json TEAM → team_members (업무는 건드리지 않음)
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { mapHrTeamMembers } from '../prisma/mapHrProjects';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const count = await prisma.teamMember.count();
|
||||||
|
if (count > 0) {
|
||||||
|
console.log('✓ Team members exist — skip TEAM seed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamParsed = mapHrTeamMembers();
|
||||||
|
if (teamParsed.length === 0) {
|
||||||
|
console.log('✓ hr-data.json TEAM empty — skip');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tm of teamParsed) {
|
||||||
|
await prisma.teamMember.create({
|
||||||
|
data: {
|
||||||
|
name: tm.name,
|
||||||
|
rank: tm.rank,
|
||||||
|
role: tm.role,
|
||||||
|
cell: tm.cell,
|
||||||
|
photoUrl: tm.photoUrl,
|
||||||
|
sortOrder: tm.sortOrder,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Team members seeded: ${teamParsed.length}명 (hr-data.json TEAM)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
198
backend/scripts/sync-team-photos.ts
Normal file
198
backend/scripts/sync-team-photos.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { getProjectRoot, getUploadDir, getTeamUploadDir } from '../src/lib/projectPaths';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const TEAM_ORDER = ['조태희', '최근혜', '류원준', '주완기', '정성호'];
|
||||||
|
|
||||||
|
function fileHash(filePath: string): string {
|
||||||
|
const buf = fs.readFileSync(filePath);
|
||||||
|
return crypto.createHash('md5').update(buf).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUploadPath(relative: string): string {
|
||||||
|
const clean = relative.replace(/^\//, '').replace(/^uploads\//, '');
|
||||||
|
return path.join(getUploadDir(), clean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncTeamPhotos() {
|
||||||
|
const teamDir = getTeamUploadDir();
|
||||||
|
if (!fs.existsSync(teamDir)) return;
|
||||||
|
|
||||||
|
const pngs = fs
|
||||||
|
.readdirSync(teamDir)
|
||||||
|
.filter((f) => /\.(png|jpe?g|webp|gif)$/i.test(f))
|
||||||
|
.map((f) => path.join(teamDir, f));
|
||||||
|
|
||||||
|
const byHash = new Map<string, string>();
|
||||||
|
const duplicates: string[] = [];
|
||||||
|
|
||||||
|
for (const filePath of pngs) {
|
||||||
|
const hash = fileHash(filePath);
|
||||||
|
const existing = byHash.get(hash);
|
||||||
|
if (existing) {
|
||||||
|
const keep =
|
||||||
|
fs.statSync(existing).birthtimeMs <= fs.statSync(filePath).birthtimeMs
|
||||||
|
? existing
|
||||||
|
: filePath;
|
||||||
|
duplicates.push(keep === existing ? filePath : existing);
|
||||||
|
byHash.set(hash, keep);
|
||||||
|
} else {
|
||||||
|
byHash.set(hash, filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dup of duplicates) {
|
||||||
|
fs.unlinkSync(dup);
|
||||||
|
console.log(` 🗑 duplicate removed: ${path.basename(dup)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unique = [...byHash.values()].sort(
|
||||||
|
(a, b) => fs.statSync(a).birthtimeMs - fs.statSync(b).birthtimeMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
const members = await prisma.teamMember.findMany();
|
||||||
|
const byName = new Map(members.map((m) => [m.name, m]));
|
||||||
|
|
||||||
|
for (let i = 0; i < TEAM_ORDER.length; i++) {
|
||||||
|
const name = TEAM_ORDER[i];
|
||||||
|
const member = byName.get(name);
|
||||||
|
if (!member) continue;
|
||||||
|
|
||||||
|
const photoPath = unique[i];
|
||||||
|
const photoUrl = photoPath
|
||||||
|
? `/uploads/team/${path.basename(photoPath)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await prisma.teamMember.update({
|
||||||
|
where: { id: member.id },
|
||||||
|
data: { photoUrl, sortOrder: i },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ ${name} → ${photoUrl ?? '(none)'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
if (TEAM_ORDER.includes(member.name)) continue;
|
||||||
|
const url = member.photoUrl;
|
||||||
|
if (!url) continue;
|
||||||
|
const abs = resolveUploadPath(url);
|
||||||
|
if (!fs.existsSync(abs)) {
|
||||||
|
await prisma.teamMember.update({
|
||||||
|
where: { id: member.id },
|
||||||
|
data: { photoUrl: null },
|
||||||
|
});
|
||||||
|
console.log(` ✓ cleared broken photo: ${member.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearBrokenTeamPhotoUrls() {
|
||||||
|
const members = await prisma.teamMember.findMany();
|
||||||
|
for (const member of members) {
|
||||||
|
if (!member.photoUrl) continue;
|
||||||
|
if (member.photoUrl.startsWith('http') || member.photoUrl.startsWith('data:')) continue;
|
||||||
|
const abs = resolveUploadPath(member.photoUrl);
|
||||||
|
if (!fs.existsSync(abs)) {
|
||||||
|
await prisma.teamMember.update({
|
||||||
|
where: { id: member.id },
|
||||||
|
data: { photoUrl: null },
|
||||||
|
});
|
||||||
|
console.log(` ✓ cleared missing file: ${member.name} (${member.photoUrl})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneOrphanUploads() {
|
||||||
|
const referenced = new Set<string>();
|
||||||
|
|
||||||
|
const dbFiles = await prisma.file.findMany({ select: { path: true, filename: true } });
|
||||||
|
for (const f of dbFiles) {
|
||||||
|
if (f.path) referenced.add(path.normalize(f.path));
|
||||||
|
referenced.add(path.join(getUploadDir(), f.filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await prisma.teamMember.findMany({ select: { photoUrl: true } });
|
||||||
|
for (const m of members) {
|
||||||
|
if (!m.photoUrl || m.photoUrl.startsWith('http')) continue;
|
||||||
|
referenced.add(resolveUploadPath(m.photoUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadRoot = getUploadDir();
|
||||||
|
const walk = (dir: string) => {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.name === '.gitkeep') continue;
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walk(full);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const norm = path.normalize(full);
|
||||||
|
if (!referenced.has(norm)) {
|
||||||
|
fs.unlinkSync(full);
|
||||||
|
console.log(` 🗑 orphan: ${path.relative(getProjectRoot(), full)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(uploadRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchHrDataTeamPhotos() {
|
||||||
|
const seedPath = path.join(getProjectRoot(), 'data', 'seed', 'hr-data.json');
|
||||||
|
if (!fs.existsSync(seedPath)) return;
|
||||||
|
|
||||||
|
const data = JSON.parse(fs.readFileSync(seedPath, 'utf-8')) as {
|
||||||
|
TEAM?: { name: string; photo?: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Array.isArray(data.TEAM)) return;
|
||||||
|
|
||||||
|
const members = await prisma.teamMember.findMany();
|
||||||
|
const byName = new Map(members.map((m) => [m.name, m]));
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
for (const entry of data.TEAM) {
|
||||||
|
const parsed = entry.name.match(/^(\S+)/);
|
||||||
|
const name = parsed?.[1];
|
||||||
|
const member = name ? byName.get(name) : null;
|
||||||
|
const nextPhoto = member?.photoUrl ?? undefined;
|
||||||
|
if (entry.photo !== nextPhoto) {
|
||||||
|
if (nextPhoto) entry.photo = nextPhoto;
|
||||||
|
else delete entry.photo;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
fs.writeFileSync(seedPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
console.log(' ✓ hr-data.json TEAM photos synced to uploads/team paths');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('📷 Sync team photos from uploads/team/ ...');
|
||||||
|
await syncTeamPhotos();
|
||||||
|
|
||||||
|
console.log('🔍 Clear broken photo URLs ...');
|
||||||
|
await clearBrokenTeamPhotoUrls();
|
||||||
|
|
||||||
|
console.log('🧹 Remove orphan uploads (not in DB) ...');
|
||||||
|
await pruneOrphanUploads();
|
||||||
|
|
||||||
|
console.log('📝 Update hr-data.json TEAM photo paths ...');
|
||||||
|
await patchHrDataTeamPhotos();
|
||||||
|
|
||||||
|
console.log('Done.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -8,10 +8,20 @@ import routes from './routes';
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(helmet());
|
// Vercel 프론트에서 Render /uploads 이미지를 img로 불러올 수 있도록 cross-origin 허용
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
'http://localhost:3000',
|
'http://localhost:3000',
|
||||||
|
'http://localhost:5173',
|
||||||
|
'https://localhost:3000',
|
||||||
|
'http://127.0.0.1:3000',
|
||||||
|
'https://127.0.0.1:3000',
|
||||||
'http://172.16.8.248:3000',
|
'http://172.16.8.248:3000',
|
||||||
|
'https://172.16.8.248:3000',
|
||||||
'https://eene-dashboard.vercel.app',
|
'https://eene-dashboard.vercel.app',
|
||||||
process.env.FRONTEND_URL,
|
process.env.FRONTEND_URL,
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
@@ -19,6 +29,11 @@ const allowedOrigins = [
|
|||||||
function isAllowedOrigin(origin: string): boolean {
|
function isAllowedOrigin(origin: string): boolean {
|
||||||
if (allowedOrigins.includes(origin)) return true;
|
if (allowedOrigins.includes(origin)) return true;
|
||||||
if (/^https:\/\/[\w-]+\.vercel\.app$/.test(origin)) return true;
|
if (/^https:\/\/[\w-]+\.vercel\.app$/.test(origin)) return true;
|
||||||
|
// 로컬·사설망 프론트 (http/https, LAN IP)
|
||||||
|
if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) return true;
|
||||||
|
if (/^https?:\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
|
||||||
|
if (/^https?:\/\/192\.168\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
|
||||||
|
if (/^https?:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { Server } from 'socket.io';
|
|||||||
import app from './app';
|
import app from './app';
|
||||||
import { setupSocketHandlers } from './socket';
|
import { setupSocketHandlers } from './socket';
|
||||||
import { prisma } from './lib/prisma';
|
import { prisma } from './lib/prisma';
|
||||||
|
import { ensureLocalDirs } from './lib/ensureLocalDirs';
|
||||||
|
import { getProjectRoot, getHrSeedPath, getUploadDir } from './lib/projectPaths';
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT) || 4000;
|
const PORT = Number(process.env.PORT) || 4000;
|
||||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
|
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||||
@@ -12,16 +14,22 @@ const httpServer = createServer(app);
|
|||||||
|
|
||||||
const io = new Server(httpServer, {
|
const io = new Server(httpServer, {
|
||||||
cors: {
|
cors: {
|
||||||
origin: FRONTEND_URL,
|
origin: (_origin, callback) => callback(null, true),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setupSocketHandlers(io);
|
setupSocketHandlers(io);
|
||||||
|
app.set('io', io);
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
ensureLocalDirs();
|
||||||
await prisma.$connect();
|
await prisma.$connect();
|
||||||
console.log('✅ Database connected');
|
console.log('✅ Database connected — local PostgreSQL (data/postgres)');
|
||||||
|
|
||||||
|
console.log(`📂 Project root: ${getProjectRoot()}`);
|
||||||
|
console.log(`📂 HR seed: ${getHrSeedPath()}`);
|
||||||
|
console.log(`📂 Uploads: ${getUploadDir()}`);
|
||||||
|
|
||||||
httpServer.listen(PORT, '0.0.0.0', () => {
|
httpServer.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`✅ Server running on http://0.0.0.0:${PORT} (팀원: http://<이PC의IP>:${PORT})`);
|
console.log(`✅ Server running on http://0.0.0.0:${PORT} (팀원: http://<이PC의IP>:${PORT})`);
|
||||||
|
|||||||
6
backend/src/lib/ensureLocalDirs.ts
Normal file
6
backend/src/lib/ensureLocalDirs.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { ensureProjectDataDirs } from './projectPaths';
|
||||||
|
|
||||||
|
/** 로컬 data·uploads 폴더 생성 (DB·파일 영구 저장) */
|
||||||
|
export function ensureLocalDirs() {
|
||||||
|
ensureProjectDataDirs();
|
||||||
|
}
|
||||||
60
backend/src/lib/fileMime.ts
Normal file
60
backend/src/lib/fileMime.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
const EXT_MIME: Record<string, string> = {
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
gif: 'image/gif',
|
||||||
|
webp: 'image/webp',
|
||||||
|
bmp: 'image/bmp',
|
||||||
|
svg: 'image/svg+xml',
|
||||||
|
mp4: 'video/mp4',
|
||||||
|
mov: 'video/quicktime',
|
||||||
|
avi: 'video/x-msvideo',
|
||||||
|
webm: 'video/webm',
|
||||||
|
mkv: 'video/x-matroska',
|
||||||
|
m4v: 'video/x-m4v',
|
||||||
|
wmv: 'video/x-ms-wmv',
|
||||||
|
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
xls: 'application/vnd.ms-excel',
|
||||||
|
csv: 'text/csv',
|
||||||
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
doc: 'application/msword',
|
||||||
|
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
ppt: 'application/vnd.ms-powerpoint',
|
||||||
|
hwp: 'application/x-hwp',
|
||||||
|
hwpx: 'application/hwp+zip',
|
||||||
|
txt: 'text/plain',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** multer가 application/octet-stream 으로 저장할 때 확장자로 보정 */
|
||||||
|
export function resolveFileMime(originalName: string, stored?: string | null): string {
|
||||||
|
const ext = originalName.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
const fromExt = ext ? EXT_MIME[ext] : undefined;
|
||||||
|
|
||||||
|
if (!stored || stored === 'application/octet-stream' || stored === 'application/x-msdownload') {
|
||||||
|
return fromExt ?? stored ?? 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromExt && stored.startsWith('application/') && stored.includes('octet')) {
|
||||||
|
return fromExt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInlinePreviewMime(mime: string): boolean {
|
||||||
|
return (
|
||||||
|
mime.startsWith('image/') ||
|
||||||
|
mime.startsWith('video/') ||
|
||||||
|
mime.startsWith('text/') ||
|
||||||
|
mime === 'application/pdf' ||
|
||||||
|
mime.includes('spreadsheet') ||
|
||||||
|
mime.includes('excel') ||
|
||||||
|
mime.includes('wordprocessingml') ||
|
||||||
|
mime.includes('presentationml') ||
|
||||||
|
mime === 'application/msword' ||
|
||||||
|
mime === 'application/vnd.ms-powerpoint' ||
|
||||||
|
mime === 'application/x-hwp' ||
|
||||||
|
mime === 'application/hwp+zip'
|
||||||
|
);
|
||||||
|
}
|
||||||
140
backend/src/lib/milestonePeriods.ts
Normal file
140
backend/src/lib/milestonePeriods.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
export interface MilestonePeriodEntry {
|
||||||
|
id: string;
|
||||||
|
startDate?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDay(iso: string): Date {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePeriodEntries(raw: unknown): MilestonePeriodEntry[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const entries: MilestonePeriodEntry[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
if (!item || typeof item !== 'object') continue;
|
||||||
|
const row = item as Record<string, unknown>;
|
||||||
|
const startDate = typeof row.startDate === 'string' ? row.startDate.trim() || null : null;
|
||||||
|
const dueDate = typeof row.dueDate === 'string' ? row.dueDate.trim() || null : null;
|
||||||
|
const note = typeof row.note === 'string' ? row.note.trim() || null : null;
|
||||||
|
if (!startDate && !dueDate && !note) continue;
|
||||||
|
entries.push({
|
||||||
|
id: typeof row.id === 'string' && row.id ? row.id : `period-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
|
startDate,
|
||||||
|
dueDate,
|
||||||
|
note,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveMilestoneDatesFromPeriods(entries: MilestonePeriodEntry[]): {
|
||||||
|
startDate: Date | null;
|
||||||
|
dueDate: Date | null;
|
||||||
|
} {
|
||||||
|
const times: number[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.startDate) times.push(parseDay(entry.startDate).getTime());
|
||||||
|
if (entry.dueDate) times.push(parseDay(entry.dueDate).getTime());
|
||||||
|
}
|
||||||
|
if (times.length === 0) return { startDate: null, dueDate: null };
|
||||||
|
return {
|
||||||
|
startDate: new Date(Math.min(...times)),
|
||||||
|
dueDate: new Date(Math.max(...times)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMilestonePeriodPayload(body: Record<string, unknown>): {
|
||||||
|
periodEntries: MilestonePeriodEntry[] | undefined;
|
||||||
|
startDate: Date | null | undefined;
|
||||||
|
dueDate: Date | null | undefined;
|
||||||
|
} {
|
||||||
|
if (body.periodEntries !== undefined) {
|
||||||
|
const periodEntries = normalizePeriodEntries(body.periodEntries);
|
||||||
|
const { startDate, dueDate } = deriveMilestoneDatesFromPeriods(periodEntries);
|
||||||
|
return {
|
||||||
|
periodEntries,
|
||||||
|
startDate,
|
||||||
|
dueDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLegacyStart = body.startDate !== undefined;
|
||||||
|
const hasLegacyDue = body.dueDate !== undefined;
|
||||||
|
if (!hasLegacyStart && !hasLegacyDue) {
|
||||||
|
return { periodEntries: undefined, startDate: undefined, dueDate: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = body.startDate ? new Date(String(body.startDate)) : null;
|
||||||
|
const dueDate = body.dueDate ? new Date(String(body.dueDate)) : null;
|
||||||
|
const periodEntries =
|
||||||
|
startDate || dueDate
|
||||||
|
? normalizePeriodEntries([
|
||||||
|
{
|
||||||
|
id: `period-legacy-${Date.now()}`,
|
||||||
|
startDate: startDate ? startDate.toISOString().slice(0, 10) : null,
|
||||||
|
dueDate: dueDate ? dueDate.toISOString().slice(0, 10) : null,
|
||||||
|
note: null,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return { periodEntries, startDate, dueDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateInput(d: Date | null | undefined): string | null {
|
||||||
|
return d ? d.toISOString().slice(0, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 레거시 description(@overview: 포함) → 기간1 note 본문 */
|
||||||
|
export function legacyDescriptionNote(description: string | null | undefined): string {
|
||||||
|
if (!description?.trim()) return '';
|
||||||
|
if (description.startsWith('@overview:')) {
|
||||||
|
const rest = description.slice('@overview:'.length);
|
||||||
|
const nl = rest.indexOf('\n');
|
||||||
|
if (nl === -1) return rest.trim();
|
||||||
|
const body = rest.slice(nl + 1).trim();
|
||||||
|
if (body) return body;
|
||||||
|
return rest.slice(0, nl).trim();
|
||||||
|
}
|
||||||
|
return description.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 공통업무내용(description)을 periodEntries[0].note로 이관 */
|
||||||
|
export function migrateDescriptionToPeriodEntries(input: {
|
||||||
|
id?: string;
|
||||||
|
periodEntries: unknown;
|
||||||
|
startDate: Date | null;
|
||||||
|
dueDate: Date | null;
|
||||||
|
description: string | null;
|
||||||
|
}): { periodEntries: MilestonePeriodEntry[] | null; migrated: boolean } {
|
||||||
|
const legacyNote = legacyDescriptionNote(input.description);
|
||||||
|
if (!legacyNote) {
|
||||||
|
return { periodEntries: normalizePeriodEntries(input.periodEntries), migrated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = normalizePeriodEntries(input.periodEntries);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
if (existing.some((e) => e.note?.trim())) {
|
||||||
|
return { periodEntries: existing, migrated: false };
|
||||||
|
}
|
||||||
|
const updated = [...existing];
|
||||||
|
updated[0] = { ...updated[0], note: legacyNote };
|
||||||
|
return { periodEntries: updated, migrated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = input.id ? `period-legacy-${input.id}` : `period-legacy-${Date.now()}`;
|
||||||
|
return {
|
||||||
|
periodEntries: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
startDate: toDateInput(input.startDate),
|
||||||
|
dueDate: toDateInput(input.dueDate),
|
||||||
|
note: legacyNote,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
migrated: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
65
backend/src/lib/projectPaths.ts
Normal file
65
backend/src/lib/projectPaths.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/** backend/ 디렉터리 (package.json 기준) */
|
||||||
|
export function getBackendRoot(): string {
|
||||||
|
return path.resolve(__dirname, '../..');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EENE_Dashboard_0608 루트 */
|
||||||
|
export function getProjectRoot(): string {
|
||||||
|
return path.resolve(getBackendRoot(), '..');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDataDir(): string {
|
||||||
|
return path.join(getProjectRoot(), 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPostgresDataDir(): string {
|
||||||
|
return path.join(getDataDir(), 'postgres');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSeedDir(): string {
|
||||||
|
return path.join(getDataDir(), 'seed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HR 원본 JSON — seed·import 공통 (프로젝트 내부 data/seed/) */
|
||||||
|
export function getHrSeedPath(): string {
|
||||||
|
const configured = process.env.HR_DATA_PATH?.trim();
|
||||||
|
if (configured) {
|
||||||
|
return path.isAbsolute(configured)
|
||||||
|
? configured
|
||||||
|
: path.resolve(getBackendRoot(), configured);
|
||||||
|
}
|
||||||
|
return path.join(getSeedDir(), 'hr-data.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUploadDir(): string {
|
||||||
|
const configured = process.env.UPLOAD_DIR?.trim();
|
||||||
|
if (configured) {
|
||||||
|
return path.isAbsolute(configured)
|
||||||
|
? configured
|
||||||
|
: path.resolve(getBackendRoot(), configured);
|
||||||
|
}
|
||||||
|
return path.join(getProjectRoot(), 'uploads');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTeamUploadDir(): string {
|
||||||
|
return path.join(getUploadDir(), 'team');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 서버 기동 시 data·uploads 등 로컬 영구 저장 폴더 생성 */
|
||||||
|
export function ensureProjectDataDirs(): void {
|
||||||
|
for (const dir of [
|
||||||
|
getDataDir(),
|
||||||
|
getPostgresDataDir(),
|
||||||
|
getSeedDir(),
|
||||||
|
getUploadDir(),
|
||||||
|
getTeamUploadDir(),
|
||||||
|
]) {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
console.log(`📁 Created: ${dir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
backend/src/lib/taskIssues.ts
Normal file
56
backend/src/lib/taskIssues.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
export interface TaskIssueEntry {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
showOnCard: boolean;
|
||||||
|
occurredOn?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newIssueId() {
|
||||||
|
return `issue-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOccurredOn(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||||
|
const iso = raw.trim();
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null;
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeIssueEntries(raw: unknown): TaskIssueEntry[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const entries: TaskIssueEntry[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
if (!item || typeof item !== 'object') continue;
|
||||||
|
const row = item as Record<string, unknown>;
|
||||||
|
const text = typeof row.text === 'string' ? row.text.trim() : '';
|
||||||
|
if (!text) continue;
|
||||||
|
entries.push({
|
||||||
|
id: typeof row.id === 'string' && row.id ? row.id : newIssueId(),
|
||||||
|
text,
|
||||||
|
showOnCard: row.showOnCard !== false,
|
||||||
|
occurredOn: normalizeOccurredOn(row.occurredOn),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseIssueEntriesFromTask(task: {
|
||||||
|
issueEntries?: unknown;
|
||||||
|
issueNote?: string | null;
|
||||||
|
showIssue?: boolean;
|
||||||
|
}): TaskIssueEntry[] {
|
||||||
|
const fromJson = normalizeIssueEntries(task.issueEntries);
|
||||||
|
if (fromJson.length > 0) return fromJson;
|
||||||
|
const legacy = task.issueNote?.trim();
|
||||||
|
if (!legacy) return [];
|
||||||
|
return [{ id: 'legacy', text: legacy, showOnCard: task.showIssue !== false, occurredOn: null }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveIssueFields(entries: TaskIssueEntry[]) {
|
||||||
|
const visible = entries.filter((entry) => entry.showOnCard && entry.text.trim());
|
||||||
|
return {
|
||||||
|
issueEntries: entries,
|
||||||
|
issueNote: visible.length > 0 ? visible[visible.length - 1].text : null,
|
||||||
|
showIssue: visible.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
112
backend/src/lib/taskQuery.ts
Normal file
112
backend/src/lib/taskQuery.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { prisma } from './prisma';
|
||||||
|
|
||||||
|
export const teamMemberSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
rank: true,
|
||||||
|
role: true,
|
||||||
|
cell: true,
|
||||||
|
contact: true,
|
||||||
|
photoUrl: true,
|
||||||
|
sortOrder: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const milestoneInclude = {
|
||||||
|
pmMember: { select: teamMemberSelect },
|
||||||
|
milestoneAssignees: {
|
||||||
|
include: { member: { select: teamMemberSelect } },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const taskInclude = {
|
||||||
|
assignee: { select: { id: true, name: true, department: true } },
|
||||||
|
creator: { select: { id: true, name: true } },
|
||||||
|
pmMember: { select: teamMemberSelect },
|
||||||
|
taskAssignees: {
|
||||||
|
include: { member: { select: teamMemberSelect } },
|
||||||
|
},
|
||||||
|
milestones: {
|
||||||
|
orderBy: { order: 'asc' as const },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
progress: true,
|
||||||
|
startDate: true,
|
||||||
|
dueDate: true,
|
||||||
|
periodEntries: true,
|
||||||
|
order: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { select: { files: true, details: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const taskDetailInclude = {
|
||||||
|
assignee: { select: { id: true, name: true, department: true } },
|
||||||
|
creator: { select: { id: true, name: true } },
|
||||||
|
pmMember: { select: teamMemberSelect },
|
||||||
|
taskAssignees: {
|
||||||
|
include: { member: { select: teamMemberSelect } },
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
orderBy: { createdAt: 'desc' as const },
|
||||||
|
include: { author: { select: { id: true, name: true } } },
|
||||||
|
},
|
||||||
|
kpiMetrics: true,
|
||||||
|
files: true,
|
||||||
|
milestones: {
|
||||||
|
orderBy: { order: 'asc' as const },
|
||||||
|
include: milestoneInclude,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatMilestone<T extends Record<string, unknown>>(milestone: T) {
|
||||||
|
const { milestoneAssignees, ...rest } = milestone as T & {
|
||||||
|
milestoneAssignees?: Array<{ member: unknown }>;
|
||||||
|
};
|
||||||
|
const assigneeMembers = (milestoneAssignees ?? []).map((ma) => ma.member);
|
||||||
|
return { ...rest, assigneeMembers };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTask<T extends Record<string, unknown>>(task: T) {
|
||||||
|
const { taskAssignees, milestones, ...rest } = task as T & {
|
||||||
|
taskAssignees?: Array<{ member: unknown }>;
|
||||||
|
milestones?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
const assigneeMembers = (taskAssignees ?? []).map((ta) => ta.member);
|
||||||
|
const formattedMilestones = milestones?.map((m) => formatMilestone(m));
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
assigneeMembers,
|
||||||
|
...(formattedMilestones !== undefined ? { milestones: formattedMilestones } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncTaskMembers(
|
||||||
|
taskId: string,
|
||||||
|
pmMemberId: string | null | undefined,
|
||||||
|
assigneeMemberIds: string[] | undefined,
|
||||||
|
) {
|
||||||
|
if (pmMemberId !== undefined) {
|
||||||
|
await prisma.task.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: { pmMemberId: pmMemberId || null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assigneeMemberIds !== undefined) {
|
||||||
|
await prisma.taskAssignee.deleteMany({ where: { taskId } });
|
||||||
|
const ids = [...new Set(assigneeMemberIds.filter(Boolean))];
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await prisma.taskAssignee.createMany({
|
||||||
|
data: ids.map((memberId) => ({ taskId, memberId })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMemberIds(body: Record<string, unknown>): string[] | undefined {
|
||||||
|
if (body.assigneeMemberIds === undefined) return undefined;
|
||||||
|
const raw = body.assigneeMemberIds;
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.map(String).filter(Boolean);
|
||||||
|
}
|
||||||
23
backend/src/lib/uploadErrors.ts
Normal file
23
backend/src/lib/uploadErrors.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import type { Express } from 'express';
|
||||||
|
|
||||||
|
export const DISK_FULL_MESSAGE =
|
||||||
|
'저장 공간이 부족하여 파일을 업로드하지 못했습니다. 불필요한 파일을 삭제한 후 다시 시도해 주세요.';
|
||||||
|
|
||||||
|
export function isDiskFullError(err: unknown): boolean {
|
||||||
|
if (!err || typeof err !== 'object') return false;
|
||||||
|
const e = err as NodeJS.ErrnoException & { cause?: unknown };
|
||||||
|
if (e.code === 'ENOSPC' || e.code === 'EDQUOT') return true;
|
||||||
|
if (e.cause !== undefined) return isDiskFullError(e.cause);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** multer 업로드 실패 시 디스크에 남은 임시 파일 제거 */
|
||||||
|
export function cleanupUploadedFile(file?: Express.Multer.File | null) {
|
||||||
|
if (!file?.path) return;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
import { DISK_FULL_MESSAGE, isDiskFullError } from '../lib/uploadErrors';
|
import { DISK_FULL_MESSAGE, isDiskFullError } from '../lib/uploadErrors';
|
||||||
|
|
||||||
|
|
||||||
@@ -21,11 +22,26 @@ export function errorHandler(
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function errorHandler(
|
||||||
|
|
||||||
|
err: Error,
|
||||||
|
|
||||||
|
_req: Request,
|
||||||
|
|
||||||
|
res: Response,
|
||||||
|
|
||||||
|
_next: NextFunction,
|
||||||
|
|
||||||
): void {
|
): void {
|
||||||
|
|
||||||
if (err instanceof AppError) {
|
if (err instanceof AppError) {
|
||||||
res.status(500).json({ message: 'DB 스키마가 최신이 아닙니다. 배포 후 다시 시도해 주세요.' });
|
|
||||||
|
res.status(err.statusCode).json({ message: err.message });
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import path from 'path';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
const MAX_SIZE_MB = Number(process.env.MAX_FILE_SIZE_MB) || 20;
|
|
||||||
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || '../uploads');
|
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || '../uploads');
|
||||||
|
|
||||||
if (!fs.existsSync(UPLOAD_DIR)) {
|
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||||
@@ -20,7 +19,5 @@ const storage = multer.diskStorage({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const upload = multer({
|
/** 파일 크기 상한 없음 — 디스크 여유만큼 저장 (부족 시 uploadErrors) */
|
||||||
storage,
|
export const upload = multer({ storage });
|
||||||
limits: { fileSize: MAX_SIZE_MB * 1024 * 1024 },
|
|
||||||
});
|
|
||||||
|
|||||||
32
backend/src/middleware/uploadTeamPhoto.ts
Normal file
32
backend/src/middleware/uploadTeamPhoto.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || '../uploads');
|
||||||
|
const TEAM_DIR = path.join(UPLOAD_DIR, 'team');
|
||||||
|
|
||||||
|
if (!fs.existsSync(TEAM_DIR)) {
|
||||||
|
fs.mkdirSync(TEAM_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination(_req, _file, cb) {
|
||||||
|
cb(null, TEAM_DIR);
|
||||||
|
},
|
||||||
|
filename(_req, file, cb) {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||||
|
cb(null, `${uuidv4()}${ext}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const uploadTeamPhoto = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter(_req, file, cb) {
|
||||||
|
if (/^image\/(jpeg|jpg|png|gif|webp)$/i.test(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('JPEG, PNG, GIF, WebP 이미지만 업로드할 수 있습니다.'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -10,8 +10,8 @@ const DEFAULTS: Record<string, { title: string; titleEn: string; subtitle: strin
|
|||||||
subtitle: '임직원의 몰입(Engagement)과 성장(Education)',
|
subtitle: '임직원의 몰입(Engagement)과 성장(Education)',
|
||||||
},
|
},
|
||||||
'운영관리': {
|
'운영관리': {
|
||||||
title: '운영관리 부문',
|
title: '총무관리',
|
||||||
titleEn: 'Operations',
|
titleEn: 'GA',
|
||||||
subtitle: '인프라 고도화와 자산 라이프사이클 표준화',
|
subtitle: '인프라 고도화와 자산 라이프사이클 표준화',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -27,6 +27,18 @@ router.get('/:key', async (req, res, next) => {
|
|||||||
config = await prisma.columnConfig.create({
|
config = await prisma.columnConfig.create({
|
||||||
data: { key, title: def?.title ?? key, titleEn: def?.titleEn ?? '', subtitle: def?.subtitle ?? '' },
|
data: { key, title: def?.title ?? key, titleEn: def?.titleEn ?? '', subtitle: def?.subtitle ?? '' },
|
||||||
});
|
});
|
||||||
|
} else if (key === '운영관리') {
|
||||||
|
const legacyTitles = ['운영관리', '운영관리 부문'];
|
||||||
|
const legacyTitleEn = ['Operations'];
|
||||||
|
if (legacyTitles.includes(config.title) || legacyTitleEn.includes(config.titleEn)) {
|
||||||
|
config = await prisma.columnConfig.update({
|
||||||
|
where: { key },
|
||||||
|
data: {
|
||||||
|
title: DEFAULTS[key]?.title ?? config.title,
|
||||||
|
titleEn: DEFAULTS[key]?.titleEn ?? config.titleEn,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(config);
|
res.json(config);
|
||||||
|
|||||||
@@ -1,20 +1,43 @@
|
|||||||
import { Router, type Response } from 'express';
|
import { Router, type Request, type Response } from 'express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { prisma } from '../lib/prisma';
|
import { prisma } from '../lib/prisma';
|
||||||
import { resolveTaskActorId } from '../lib/resolveUser';
|
import { resolveTaskActorId } from '../lib/resolveUser';
|
||||||
import { upload } from '../middleware/upload';
|
import { upload } from '../middleware/upload';
|
||||||
import { AppError } from '../middleware/errorHandler';
|
import { AppError } from '../middleware/errorHandler';
|
||||||
|
import { cleanupUploadedFile, DISK_FULL_MESSAGE, isDiskFullError } from '../lib/uploadErrors';
|
||||||
|
import { toHtml } from '@ohah/hwpjs';
|
||||||
|
import { resolveFileMime } from '../lib/fileMime';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
/** Vercel 상세 창에서 PDF 등 iframe 미리보기 허용 */
|
const PREVIEW_FRAME_ANCESTORS = [
|
||||||
function allowCrossOriginPreview(res: Response) {
|
"'self'",
|
||||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
'https://eene-dashboard.vercel.app',
|
||||||
res.setHeader(
|
'http://localhost:3000',
|
||||||
'Content-Security-Policy',
|
'https://localhost:3000',
|
||||||
"frame-ancestors 'self' https://eene-dashboard.vercel.app https://*.vercel.app http://localhost:3000",
|
'http://127.0.0.1:3000',
|
||||||
|
'https://127.0.0.1:3000',
|
||||||
|
];
|
||||||
|
|
||||||
|
function isPrivateDevOrigin(origin: string): boolean {
|
||||||
|
return (
|
||||||
|
/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin) ||
|
||||||
|
/^https?:\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/.test(origin) ||
|
||||||
|
/^https?:\/\/192\.168\.\d+\.\d+(:\d+)?$/.test(origin) ||
|
||||||
|
/^https?:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/.test(origin)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PDF 등 iframe 미리보기 — localhost·LAN https 포함 */
|
||||||
|
function allowCrossOriginPreview(req: Request, res: Response) {
|
||||||
|
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||||
|
const ancestors = [...PREVIEW_FRAME_ANCESTORS];
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
if (origin && isPrivateDevOrigin(origin) && !ancestors.includes(origin)) {
|
||||||
|
ancestors.push(origin);
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Security-Policy', `frame-ancestors ${ancestors.join(' ')}`);
|
||||||
res.removeHeader('X-Frame-Options');
|
res.removeHeader('X-Frame-Options');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +82,7 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) =>
|
|||||||
originalName: fixOriginalName(req.file.originalname),
|
originalName: fixOriginalName(req.file.originalname),
|
||||||
displayName,
|
displayName,
|
||||||
sortOrder: Number.isNaN(sortOrder) ? 0 : sortOrder,
|
sortOrder: Number.isNaN(sortOrder) ? 0 : sortOrder,
|
||||||
mimetype: req.file.mimetype,
|
mimetype: resolveFileMime(fixOriginalName(req.file.originalname), req.file.mimetype),
|
||||||
size: req.file.size,
|
size: req.file.size,
|
||||||
path: req.file.path,
|
path: req.file.path,
|
||||||
uploadedBy,
|
uploadedBy,
|
||||||
@@ -68,10 +91,47 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) =>
|
|||||||
|
|
||||||
res.status(201).json(fileRecord);
|
res.status(201).json(fileRecord);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isDiskFullError(err)) {
|
||||||
|
cleanupUploadedFile(req.file);
|
||||||
|
next(new AppError(507, DISK_FULL_MESSAGE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isHwpOriginalName(originalName: string): boolean {
|
||||||
|
const ext = originalName.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
return ext === 'hwp' || ext === 'hwpx';
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/files/:id/hwp-preview — 한글(.hwp/.hwpx) HTML 미리보기
|
||||||
|
router.get('/:id/hwp-preview', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const fileId = String(req.params.id);
|
||||||
|
const file = await prisma.file.findUnique({ where: { id: fileId } });
|
||||||
|
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
||||||
|
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
|
||||||
|
if (!isHwpOriginalName(file.originalName)) {
|
||||||
|
throw new AppError(400, '한글 파일만 미리보기할 수 있습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = fs.readFileSync(file.path);
|
||||||
|
const html = toHtml(data, {
|
||||||
|
includeVersion: false,
|
||||||
|
includePageInfo: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ html });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AppError) {
|
||||||
|
next(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next(new AppError(422, '한글 파일 미리보기 변환에 실패했습니다.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/files/:id/view — 파일 미리보기 (브라우저에서 바로 열기)
|
// GET /api/files/:id/view — 파일 미리보기 (브라우저에서 바로 열기)
|
||||||
router.get('/:id/view', async (req, res, next) => {
|
router.get('/:id/view', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -80,8 +140,9 @@ router.get('/:id/view', async (req, res, next) => {
|
|||||||
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
||||||
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
|
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
|
||||||
|
|
||||||
allowCrossOriginPreview(res);
|
allowCrossOriginPreview(req, res);
|
||||||
res.setHeader('Content-Type', file.mimetype);
|
const mime = resolveFileMime(file.originalName, file.mimetype);
|
||||||
|
res.setHeader('Content-Type', mime);
|
||||||
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.originalName)}"`);
|
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.originalName)}"`);
|
||||||
fs.createReadStream(file.path).pipe(res);
|
fs.createReadStream(file.path).pipe(res);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -122,7 +183,7 @@ router.post('/:id/replace', upload.single('file'), async (req, res, next) => {
|
|||||||
data: {
|
data: {
|
||||||
filename: req.file.filename,
|
filename: req.file.filename,
|
||||||
originalName: fixOriginalName(req.file.originalname),
|
originalName: fixOriginalName(req.file.originalname),
|
||||||
mimetype: req.file.mimetype,
|
mimetype: resolveFileMime(fixOriginalName(req.file.originalname), req.file.mimetype),
|
||||||
size: req.file.size,
|
size: req.file.size,
|
||||||
path: req.file.path,
|
path: req.file.path,
|
||||||
},
|
},
|
||||||
@@ -130,6 +191,11 @@ router.post('/:id/replace', upload.single('file'), async (req, res, next) => {
|
|||||||
|
|
||||||
res.json(file);
|
res.json(file);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isDiskFullError(err)) {
|
||||||
|
cleanupUploadedFile(req.file);
|
||||||
|
next(new AppError(507, DISK_FULL_MESSAGE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
83
backend/src/routes/hubConfig.ts
Normal file
83
backend/src/routes/hubConfig.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { prisma } from '../lib/prisma';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const HUB_ID = 'default';
|
||||||
|
|
||||||
|
export const DEFAULT_HUB_CONFIG = {
|
||||||
|
sloganTitle: '분기 중점 과제',
|
||||||
|
sloganLines: ['인사 · 육성 · 문화 · 총무', '개선과제', '정상 추진'],
|
||||||
|
scheduleTitle: '분기 주요 일정',
|
||||||
|
scheduleItems: [
|
||||||
|
{ id: '1', date: '2026-04-01', text: '상반기 채용·온보딩' },
|
||||||
|
{ id: '2', date: '2026-05-15', text: '조직문화 진단·리더십 교육' },
|
||||||
|
{ id: '3', date: '2026-06-20', text: '분기 성과 점검·평가' },
|
||||||
|
],
|
||||||
|
routineLabels: ['채용 운영', '학습 지원', '운영 지원', '자산·시설', '문서·행정'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function migrateRoutineLabels(labels: string[]): string[] {
|
||||||
|
return labels.map((label) => {
|
||||||
|
if (label === '교육 운영') return '학습 지원';
|
||||||
|
if (label === '직원 소통') return '운영 지원';
|
||||||
|
return label;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfig(raw: Record<string, unknown>) {
|
||||||
|
const sloganTitle = (raw.sloganTitle as string) ?? DEFAULT_HUB_CONFIG.sloganTitle;
|
||||||
|
const routineLabels = Array.isArray(raw.routineLabels)
|
||||||
|
? migrateRoutineLabels(raw.routineLabels as string[])
|
||||||
|
: DEFAULT_HUB_CONFIG.routineLabels;
|
||||||
|
return {
|
||||||
|
sloganTitle: sloganTitle === '분기 슬로건' ? '분기 중점 과제' : sloganTitle,
|
||||||
|
sloganLines: Array.isArray(raw.sloganLines)
|
||||||
|
? (raw.sloganLines as string[])
|
||||||
|
: DEFAULT_HUB_CONFIG.sloganLines,
|
||||||
|
scheduleTitle: (raw.scheduleTitle as string) ?? DEFAULT_HUB_CONFIG.scheduleTitle,
|
||||||
|
scheduleItems: Array.isArray(raw.scheduleItems)
|
||||||
|
? (raw.scheduleItems as typeof DEFAULT_HUB_CONFIG.scheduleItems)
|
||||||
|
: DEFAULT_HUB_CONFIG.scheduleItems,
|
||||||
|
routineLabels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrCreateHubConfig() {
|
||||||
|
let row = await prisma.hubConfig.findUnique({ where: { id: HUB_ID } });
|
||||||
|
if (!row) {
|
||||||
|
row = await prisma.hubConfig.create({
|
||||||
|
data: { id: HUB_ID, config: DEFAULT_HUB_CONFIG },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/hub-config
|
||||||
|
router.get('/', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const row = await getOrCreateHubConfig();
|
||||||
|
res.json(normalizeConfig(row.config as Record<string, unknown>));
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/hub-config
|
||||||
|
router.patch('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const row = await getOrCreateHubConfig();
|
||||||
|
const merged = normalizeConfig({
|
||||||
|
...(row.config as Record<string, unknown>),
|
||||||
|
...(req.body as Record<string, unknown>),
|
||||||
|
});
|
||||||
|
const updated = await prisma.hubConfig.update({
|
||||||
|
where: { id: HUB_ID },
|
||||||
|
data: { config: merged },
|
||||||
|
});
|
||||||
|
res.json(normalizeConfig(updated.config as Record<string, unknown>));
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -7,15 +7,19 @@ import kpiRoutes from './kpi';
|
|||||||
import columnRoutes from './columns';
|
import columnRoutes from './columns';
|
||||||
import milestoneRoutes from './milestones';
|
import milestoneRoutes from './milestones';
|
||||||
import detailRoutes from './details';
|
import detailRoutes from './details';
|
||||||
|
import teamMemberRoutes from './teamMembers';
|
||||||
|
import hubConfigRoutes from './hubConfig';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use('/auth', authRoutes);
|
router.use('/auth', authRoutes);
|
||||||
router.use('/tasks', taskRoutes);
|
router.use('/tasks', taskRoutes);
|
||||||
|
router.use('/team-members', teamMemberRoutes);
|
||||||
router.use('/users', userRoutes);
|
router.use('/users', userRoutes);
|
||||||
router.use('/files', fileRoutes);
|
router.use('/files', fileRoutes);
|
||||||
router.use('/kpi', kpiRoutes);
|
router.use('/kpi', kpiRoutes);
|
||||||
router.use('/columns', columnRoutes);
|
router.use('/columns', columnRoutes);
|
||||||
|
router.use('/hub-config', hubConfigRoutes);
|
||||||
router.use('/milestones', milestoneRoutes);
|
router.use('/milestones', milestoneRoutes);
|
||||||
router.use('/details', detailRoutes);
|
router.use('/details', detailRoutes);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { prisma } from '../lib/prisma';
|
import { prisma } from '../lib/prisma';
|
||||||
import { resolveTaskActorId } from '../lib/resolveUser';
|
import { resolveTaskActorId } from '../lib/resolveUser';
|
||||||
|
import { formatMilestone, milestoneInclude, parseMemberIds } from '../lib/taskQuery';
|
||||||
|
import { resolveMilestonePeriodPayload } from '../lib/milestonePeriods';
|
||||||
import { AppError } from '../middleware/errorHandler';
|
import { AppError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -28,14 +31,47 @@ function clampProgress(value: unknown): number {
|
|||||||
return Math.min(100, Math.max(0, Math.round(n)));
|
return Math.min(100, Math.max(0, Math.round(n)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncMilestoneMembers(
|
||||||
|
milestoneId: string,
|
||||||
|
pmMemberId: string | null | undefined,
|
||||||
|
assigneeMemberIds: string[] | undefined,
|
||||||
|
) {
|
||||||
|
if (pmMemberId !== undefined) {
|
||||||
|
await prisma.milestone.update({
|
||||||
|
where: { id: milestoneId },
|
||||||
|
data: { pmMemberId: pmMemberId || null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assigneeMemberIds !== undefined) {
|
||||||
|
await prisma.milestoneAssignee.deleteMany({ where: { milestoneId } });
|
||||||
|
const ids = [...new Set(assigneeMemberIds.filter(Boolean))];
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await prisma.milestoneAssignee.createMany({
|
||||||
|
data: ids.map((memberId) => ({ milestoneId, memberId })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMilestone(id: string) {
|
||||||
|
const milestone = await prisma.milestone.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: milestoneInclude,
|
||||||
|
});
|
||||||
|
if (!milestone) throw new AppError(404, '단계를 찾을 수 없습니다.');
|
||||||
|
return formatMilestone(milestone);
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/milestones/:taskId
|
// GET /api/milestones/:taskId
|
||||||
router.get('/:taskId', async (req, res, next) => {
|
router.get('/:taskId', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const milestones = await prisma.milestone.findMany({
|
const milestones = await prisma.milestone.findMany({
|
||||||
where: { taskId: req.params.taskId },
|
where: { taskId: req.params.taskId },
|
||||||
orderBy: { order: 'asc' },
|
orderBy: { order: 'asc' },
|
||||||
|
include: milestoneInclude,
|
||||||
});
|
});
|
||||||
res.json(milestones);
|
res.json(milestones.map((m) => formatMilestone(m)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
@@ -45,26 +81,48 @@ router.get('/:taskId', async (req, res, next) => {
|
|||||||
router.post('/:taskId', async (req, res, next) => {
|
router.post('/:taskId', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const taskId = req.params.taskId;
|
const taskId = req.params.taskId;
|
||||||
const { title, description, startDate, dueDate, feedback, links, progress } =
|
const body = req.body as Record<string, unknown>;
|
||||||
req.body as Record<string, string | number>;
|
const { title, subtitle, description, startDate, dueDate, feedback, links, progress } = body;
|
||||||
|
const assigneeMemberIds = parseMemberIds(body);
|
||||||
|
const pmMemberId =
|
||||||
|
body.pmMemberId !== undefined ? String(body.pmMemberId || '') || null : undefined;
|
||||||
|
|
||||||
if (!title?.toString().trim()) throw new AppError(400, '단계 제목은 필수입니다.');
|
if (!title?.toString().trim()) throw new AppError(400, '단계 제목은 필수입니다.');
|
||||||
|
|
||||||
const count = await prisma.milestone.count({ where: { taskId } });
|
const count = await prisma.milestone.count({ where: { taskId } });
|
||||||
|
const periodPayload = resolveMilestonePeriodPayload(body);
|
||||||
|
|
||||||
const milestone = await prisma.milestone.create({
|
const milestone = await prisma.milestone.create({
|
||||||
data: {
|
data: {
|
||||||
taskId,
|
taskId,
|
||||||
title: String(title).trim(),
|
title: String(title).trim(),
|
||||||
|
subtitle: subtitle !== undefined ? String(subtitle || '').trim() || null : null,
|
||||||
description: description?.toString().trim() || null,
|
description: description?.toString().trim() || null,
|
||||||
startDate: startDate ? new Date(String(startDate)) : null,
|
startDate:
|
||||||
dueDate: dueDate ? new Date(String(dueDate)) : null,
|
periodPayload.startDate !== undefined
|
||||||
|
? periodPayload.startDate
|
||||||
|
: startDate
|
||||||
|
? new Date(String(startDate))
|
||||||
|
: null,
|
||||||
|
dueDate:
|
||||||
|
periodPayload.dueDate !== undefined
|
||||||
|
? periodPayload.dueDate
|
||||||
|
: dueDate
|
||||||
|
? new Date(String(dueDate))
|
||||||
|
: null,
|
||||||
|
periodEntries:
|
||||||
|
periodPayload.periodEntries !== undefined
|
||||||
|
? (periodPayload.periodEntries as Prisma.InputJsonValue)
|
||||||
|
: undefined,
|
||||||
progress: progress !== undefined ? clampProgress(progress) : 0,
|
progress: progress !== undefined ? clampProgress(progress) : 0,
|
||||||
links: normalizeLinks(links),
|
links: normalizeLinks(links),
|
||||||
order: count,
|
order: count,
|
||||||
|
...(pmMemberId !== undefined ? { pmMemberId } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await syncMilestoneMembers(milestone.id, pmMemberId, assigneeMemberIds);
|
||||||
|
|
||||||
if (feedback?.toString().trim()) {
|
if (feedback?.toString().trim()) {
|
||||||
const updatedBy = await resolveTaskActorId(taskId);
|
const updatedBy = await resolveTaskActorId(taskId);
|
||||||
await prisma.taskDetail.create({
|
await prisma.taskDetail.create({
|
||||||
@@ -77,7 +135,7 @@ router.post('/:taskId', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(201).json(milestone);
|
res.status(201).json(await loadMilestone(milestone.id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
@@ -86,19 +144,33 @@ router.post('/:taskId', async (req, res, next) => {
|
|||||||
// PATCH /api/milestones/item/:id
|
// PATCH /api/milestones/item/:id
|
||||||
router.patch('/item/:id', async (req, res, next) => {
|
router.patch('/item/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { title, description, startDate, dueDate, feedback, links, progress, completed, order } =
|
const body = req.body as Record<string, unknown>;
|
||||||
req.body as Record<string, string | boolean | number>;
|
const { title, subtitle, description, startDate, dueDate, feedback, links, progress, completed, order } =
|
||||||
|
body;
|
||||||
|
const assigneeMemberIds = parseMemberIds(body);
|
||||||
|
const pmMemberId =
|
||||||
|
body.pmMemberId !== undefined ? String(body.pmMemberId || '') || null : undefined;
|
||||||
|
|
||||||
const existing = await prisma.milestone.findUnique({ where: { id: req.params.id } });
|
const existing = await prisma.milestone.findUnique({ where: { id: req.params.id } });
|
||||||
if (!existing) throw new AppError(404, '단계를 찾을 수 없습니다.');
|
if (!existing) throw new AppError(404, '단계를 찾을 수 없습니다.');
|
||||||
|
|
||||||
|
const periodPayload = resolveMilestonePeriodPayload(body);
|
||||||
|
|
||||||
const milestone = await prisma.milestone.update({
|
const milestone = await prisma.milestone.update({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
data: {
|
data: {
|
||||||
...(title !== undefined && { title: String(title).trim() }),
|
...(title !== undefined && { title: String(title).trim() }),
|
||||||
|
...(subtitle !== undefined && { subtitle: String(subtitle || '').trim() || null }),
|
||||||
...(description !== undefined && { description: description ? String(description).trim() : null }),
|
...(description !== undefined && { description: description ? String(description).trim() : null }),
|
||||||
...(startDate !== undefined && { startDate: startDate ? new Date(String(startDate)) : null }),
|
...(periodPayload.startDate !== undefined && { startDate: periodPayload.startDate }),
|
||||||
...(dueDate !== undefined && { dueDate: dueDate ? new Date(String(dueDate)) : null }),
|
...(periodPayload.dueDate !== undefined && { dueDate: periodPayload.dueDate }),
|
||||||
|
...(periodPayload.periodEntries !== undefined && {
|
||||||
|
periodEntries: periodPayload.periodEntries as Prisma.InputJsonValue,
|
||||||
|
}),
|
||||||
|
...(periodPayload.startDate === undefined &&
|
||||||
|
startDate !== undefined && { startDate: startDate ? new Date(String(startDate)) : null }),
|
||||||
|
...(periodPayload.dueDate === undefined &&
|
||||||
|
dueDate !== undefined && { dueDate: dueDate ? new Date(String(dueDate)) : null }),
|
||||||
...(progress !== undefined && { progress: clampProgress(progress) }),
|
...(progress !== undefined && { progress: clampProgress(progress) }),
|
||||||
...(links !== undefined && { links: normalizeLinks(links) }),
|
...(links !== undefined && { links: normalizeLinks(links) }),
|
||||||
...(order !== undefined && { order: Number(order) }),
|
...(order !== undefined && { order: Number(order) }),
|
||||||
@@ -106,9 +178,12 @@ router.patch('/item/:id', async (req, res, next) => {
|
|||||||
completedAt: completed ? new Date() : null,
|
completedAt: completed ? new Date() : null,
|
||||||
...(completed && { progress: 100 }),
|
...(completed && { progress: 100 }),
|
||||||
}),
|
}),
|
||||||
|
...(pmMemberId !== undefined ? { pmMemberId } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await syncMilestoneMembers(milestone.id, pmMemberId, assigneeMemberIds);
|
||||||
|
|
||||||
if (typeof feedback === 'string' && feedback.trim()) {
|
if (typeof feedback === 'string' && feedback.trim()) {
|
||||||
const updatedBy = await resolveTaskActorId(existing.taskId);
|
const updatedBy = await resolveTaskActorId(existing.taskId);
|
||||||
await prisma.taskDetail.create({
|
await prisma.taskDetail.create({
|
||||||
@@ -121,7 +196,7 @@ router.patch('/item/:id', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(milestone);
|
res.json(await loadMilestone(milestone.id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,35 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import type { Server } from 'socket.io';
|
||||||
import { prisma } from '../lib/prisma';
|
import { prisma } from '../lib/prisma';
|
||||||
import { resolveCreatorId } from '../lib/resolveUser';
|
import { resolveCreatorId } from '../lib/resolveUser';
|
||||||
import { AppError } from '../middleware/errorHandler';
|
import { AppError } from '../middleware/errorHandler';
|
||||||
|
import { emitTaskListRefresh, emitTaskUpdated } from '../socket';
|
||||||
|
import {
|
||||||
|
formatTask,
|
||||||
|
parseMemberIds,
|
||||||
|
syncTaskMembers,
|
||||||
|
taskDetailInclude,
|
||||||
|
taskInclude,
|
||||||
|
} from '../lib/taskQuery';
|
||||||
|
import { deriveIssueFields, normalizeIssueEntries } from '../lib/taskIssues';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
function resolveIssuePayload(body: Record<string, any>) {
|
||||||
|
if (body.issueEntries !== undefined) {
|
||||||
|
const entries = normalizeIssueEntries(body.issueEntries);
|
||||||
|
return deriveIssueFields(entries);
|
||||||
|
}
|
||||||
|
if (body.issueNote !== undefined) {
|
||||||
|
const text = typeof body.issueNote === 'string' ? body.issueNote.trim() : '';
|
||||||
|
if (!text) {
|
||||||
|
return { issueEntries: [], issueNote: null, showIssue: false };
|
||||||
|
}
|
||||||
|
return deriveIssueFields([{ id: 'legacy', text, showOnCard: body.showIssue !== false }]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/tasks — 목록 조회 (필터: status, quarter, assigneeId)
|
// GET /api/tasks — 목록 조회 (필터: status, quarter, assigneeId)
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -17,15 +42,11 @@ router.get('/', async (req, res, next) => {
|
|||||||
...(assigneeId && { assigneeId }),
|
...(assigneeId && { assigneeId }),
|
||||||
...(category && { category }),
|
...(category && { category }),
|
||||||
},
|
},
|
||||||
include: {
|
include: taskInclude,
|
||||||
assignee: { select: { id: true, name: true, department: true } },
|
|
||||||
creator: { select: { id: true, name: true } },
|
|
||||||
_count: { select: { files: true, details: true } },
|
|
||||||
},
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(tasks);
|
res.json(tasks.map((t) => formatTask(t)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
@@ -37,21 +58,11 @@ router.get('/:id', async (req, res, next) => {
|
|||||||
const taskId = String(req.params.id);
|
const taskId = String(req.params.id);
|
||||||
const task = await prisma.task.findUnique({
|
const task = await prisma.task.findUnique({
|
||||||
where: { id: taskId },
|
where: { id: taskId },
|
||||||
include: {
|
include: taskDetailInclude,
|
||||||
assignee: { select: { id: true, name: true, department: true } },
|
|
||||||
creator: { select: { id: true, name: true } },
|
|
||||||
details: {
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
include: { author: { select: { id: true, name: true } } },
|
|
||||||
},
|
|
||||||
kpiMetrics: true,
|
|
||||||
files: true,
|
|
||||||
milestones: { orderBy: { order: 'asc' } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!task) throw new AppError(404, '업무를 찾을 수 없습니다.');
|
if (!task) throw new AppError(404, '업무를 찾을 수 없습니다.');
|
||||||
res.json(task);
|
res.json(formatTask(task));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
@@ -60,21 +71,24 @@ router.get('/:id', async (req, res, next) => {
|
|||||||
// POST /api/tasks — 업무 등록
|
// POST /api/tasks — 업무 등록
|
||||||
router.post('/', async (req, res, next) => {
|
router.post('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { title, description, status, priority, quarter, category,
|
const body = req.body as Record<string, any>;
|
||||||
|
const { title, description, detailDescription, status, priority, quarter, category,
|
||||||
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
|
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
|
||||||
showDescription, showStatus, showIssue, showProgress, keywords } =
|
showDescription, showStatus, showIssue, showProgress, pmMemberId } = body;
|
||||||
req.body as Record<string, any>;
|
|
||||||
|
|
||||||
if (!title || !quarter) {
|
if (!title || !quarter) {
|
||||||
throw new AppError(400, '제목과 분기는 필수입니다.');
|
throw new AppError(400, '제목과 분기는 필수입니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const creatorId = await resolveCreatorId((req.body as Record<string, string>).creatorId);
|
const creatorId = await resolveCreatorId(body.creatorId);
|
||||||
|
const assigneeMemberIds = parseMemberIds(body);
|
||||||
|
const issuePayload = resolveIssuePayload(body);
|
||||||
|
|
||||||
const task = await prisma.task.create({
|
const task = await prisma.task.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
detailDescription: detailDescription ?? null,
|
||||||
status: (status as any) || 'TODO',
|
status: (status as any) || 'TODO',
|
||||||
priority: (priority as any) || 'MEDIUM',
|
priority: (priority as any) || 'MEDIUM',
|
||||||
quarter,
|
quarter,
|
||||||
@@ -83,21 +97,33 @@ router.post('/', async (req, res, next) => {
|
|||||||
tag,
|
tag,
|
||||||
taskType,
|
taskType,
|
||||||
progress: progress ? Number(progress) : 0,
|
progress: progress ? Number(progress) : 0,
|
||||||
issueNote: issueNote || null,
|
issueNote: issuePayload?.issueNote ?? (issueNote || null),
|
||||||
|
issueEntries: issuePayload?.issueEntries as any ?? undefined,
|
||||||
startDate: startDate ? new Date(startDate) : undefined,
|
startDate: startDate ? new Date(startDate) : undefined,
|
||||||
dueDate: dueDate ? new Date(dueDate) : undefined,
|
dueDate: dueDate ? new Date(dueDate) : undefined,
|
||||||
showDate: showDate !== undefined ? showDate === 'true' || showDate === true : true,
|
showDate: showDate !== undefined ? showDate === 'true' || showDate === true : true,
|
||||||
showDescription: showDescription !== undefined ? showDescription === 'true' || showDescription === true : true,
|
showDescription: showDescription !== undefined ? showDescription === 'true' || showDescription === true : true,
|
||||||
showStatus: showStatus !== undefined ? showStatus === 'true' || showStatus === true : true,
|
showStatus: showStatus !== undefined ? showStatus === 'true' || showStatus === true : true,
|
||||||
showIssue: showIssue !== undefined ? showIssue === 'true' || showIssue === true : true,
|
showIssue: issuePayload?.showIssue ?? (showIssue !== undefined ? showIssue === 'true' || showIssue === true : true),
|
||||||
showProgress: showProgress !== undefined ? showProgress === 'true' || showProgress === true : true,
|
showProgress: showProgress !== undefined ? showProgress === 'true' || showProgress === true : true,
|
||||||
keywords: keywords || null,
|
|
||||||
assigneeId: assigneeId || null,
|
assigneeId: assigneeId || null,
|
||||||
|
pmMemberId: pmMemberId || null,
|
||||||
creatorId,
|
creatorId,
|
||||||
},
|
},
|
||||||
|
include: taskInclude,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json(task);
|
if (assigneeMemberIds !== undefined) {
|
||||||
|
await syncTaskMembers(task.id, undefined, assigneeMemberIds);
|
||||||
|
const refreshed = await prisma.task.findUnique({
|
||||||
|
where: { id: task.id },
|
||||||
|
include: taskInclude,
|
||||||
|
});
|
||||||
|
res.status(201).json(formatTask(refreshed!));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json(formatTask(task));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
@@ -109,16 +135,20 @@ router.patch('/:id', async (req, res, next) => {
|
|||||||
const existing = await prisma.task.findUnique({ where: { id: req.params.id } });
|
const existing = await prisma.task.findUnique({ where: { id: req.params.id } });
|
||||||
if (!existing) throw new AppError(404, '업무를 찾을 수 없습니다.');
|
if (!existing) throw new AppError(404, '업무를 찾을 수 없습니다.');
|
||||||
|
|
||||||
const { title, description, status, priority, quarter, category,
|
const body = req.body as Record<string, any>;
|
||||||
|
const { title, description, detailDescription, status, priority, quarter, category,
|
||||||
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
|
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
|
||||||
showDescription, showStatus, showIssue, showProgress, keywords } =
|
showDescription, showStatus, showIssue, showProgress, pmMemberId } = body;
|
||||||
req.body as Record<string, any>;
|
|
||||||
|
|
||||||
const task = await prisma.task.update({
|
const assigneeMemberIds = parseMemberIds(body);
|
||||||
|
const issuePayload = resolveIssuePayload(body);
|
||||||
|
|
||||||
|
await prisma.task.update({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
data: {
|
data: {
|
||||||
...(title && { title }),
|
...(title && { title }),
|
||||||
...(description !== undefined && { description }),
|
...(description !== undefined && { description }),
|
||||||
|
...(detailDescription !== undefined && { detailDescription: detailDescription || null }),
|
||||||
...(status && { status: status as any }),
|
...(status && { status: status as any }),
|
||||||
...(priority && { priority: priority as any }),
|
...(priority && { priority: priority as any }),
|
||||||
...(quarter && { quarter }),
|
...(quarter && { quarter }),
|
||||||
@@ -127,20 +157,53 @@ router.patch('/:id', async (req, res, next) => {
|
|||||||
...(tag !== undefined && { tag }),
|
...(tag !== undefined && { tag }),
|
||||||
...(taskType !== undefined && { taskType }),
|
...(taskType !== undefined && { taskType }),
|
||||||
...(progress !== undefined && { progress: Number(progress) }),
|
...(progress !== undefined && { progress: Number(progress) }),
|
||||||
...(issueNote !== undefined && { issueNote: issueNote || null }),
|
...(issuePayload
|
||||||
|
? {
|
||||||
|
issueEntries: issuePayload.issueEntries as any,
|
||||||
|
issueNote: issuePayload.issueNote,
|
||||||
|
showIssue: issuePayload.showIssue,
|
||||||
|
}
|
||||||
|
: issueNote !== undefined
|
||||||
|
? {
|
||||||
|
issueNote: issueNote || null,
|
||||||
|
...(issueNote
|
||||||
|
? {}
|
||||||
|
: { issueEntries: [] as any, showIssue: false }),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(startDate !== undefined && { startDate: startDate ? new Date(startDate) : null }),
|
...(startDate !== undefined && { startDate: startDate ? new Date(startDate) : null }),
|
||||||
...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate) : null }),
|
...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate) : null }),
|
||||||
...(assigneeId !== undefined && { assigneeId: assigneeId || null }),
|
...(assigneeId !== undefined && { assigneeId: assigneeId || null }),
|
||||||
|
...(pmMemberId !== undefined && { pmMemberId: pmMemberId || null }),
|
||||||
...(showDate !== undefined && { showDate: showDate === true || showDate === 'true' }),
|
...(showDate !== undefined && { showDate: showDate === true || showDate === 'true' }),
|
||||||
...(showDescription !== undefined && { showDescription: showDescription === true || showDescription === 'true' }),
|
...(showDescription !== undefined && { showDescription: showDescription === true || showDescription === 'true' }),
|
||||||
...(showStatus !== undefined && { showStatus: showStatus === true || showStatus === 'true' }),
|
...(showStatus !== undefined && { showStatus: showStatus === true || showStatus === 'true' }),
|
||||||
...(showIssue !== undefined && { showIssue: showIssue === true || showIssue === 'true' }),
|
...(showIssue !== undefined && { showIssue: showIssue === true || showIssue === 'true' }),
|
||||||
...(showProgress !== undefined && { showProgress: showProgress === true || showProgress === 'true' }),
|
...(showProgress !== undefined && { showProgress: showProgress === true || showProgress === 'true' }),
|
||||||
...(keywords !== undefined && { keywords: keywords || null }),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(task);
|
if (pmMemberId !== undefined || assigneeMemberIds !== undefined) {
|
||||||
|
await syncTaskMembers(
|
||||||
|
req.params.id,
|
||||||
|
pmMemberId !== undefined ? (pmMemberId || null) : undefined,
|
||||||
|
assigneeMemberIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await prisma.task.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
include: taskInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatted = formatTask(task!);
|
||||||
|
const io = req.app.get('io') as Server | undefined;
|
||||||
|
if (io) {
|
||||||
|
emitTaskUpdated(io, req.params.id, formatted);
|
||||||
|
emitTaskListRefresh(io);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(formatted);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
132
backend/src/routes/teamMembers.ts
Normal file
132
backend/src/routes/teamMembers.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { prisma } from '../lib/prisma';
|
||||||
|
import { AppError } from '../middleware/errorHandler';
|
||||||
|
import { uploadTeamPhoto } from '../middleware/uploadTeamPhoto';
|
||||||
|
import { cleanupUploadedFile, DISK_FULL_MESSAGE, isDiskFullError } from '../lib/uploadErrors';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const memberSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
rank: true,
|
||||||
|
role: true,
|
||||||
|
cell: true,
|
||||||
|
contact: true,
|
||||||
|
photoUrl: true,
|
||||||
|
sortOrder: true,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /api/team-members (?all=1 이면 비활성 포함 — 관리 화면용)
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const includeAll = req.query.all === '1' || req.query.all === 'true';
|
||||||
|
const members = await prisma.teamMember.findMany({
|
||||||
|
where: includeAll ? undefined : { isActive: true },
|
||||||
|
select: memberSelect,
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||||
|
});
|
||||||
|
res.json(members);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/team-members/photo — 팀원 사진 (로컬 uploads/team/ 저장)
|
||||||
|
router.post('/photo', uploadTeamPhoto.single('photo'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
throw new AppError(400, '이미지 파일을 선택해 주세요.');
|
||||||
|
}
|
||||||
|
res.status(201).json({
|
||||||
|
url: `/uploads/team/${req.file.filename}`,
|
||||||
|
filename: req.file.filename,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (isDiskFullError(err)) {
|
||||||
|
cleanupUploadedFile(req.file);
|
||||||
|
next(new AppError(507, DISK_FULL_MESSAGE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/team-members — 인원 등록
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, rank, role, cell, contact, photoUrl, sortOrder } =
|
||||||
|
req.body as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||||
|
throw new AppError(400, '이름은 필수입니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await prisma.teamMember.create({
|
||||||
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
rank: typeof rank === 'string' ? rank : null,
|
||||||
|
role: typeof role === 'string' ? role : null,
|
||||||
|
cell: typeof cell === 'string' ? cell : null,
|
||||||
|
contact: typeof contact === 'string' ? contact : null,
|
||||||
|
photoUrl: typeof photoUrl === 'string' ? photoUrl : null,
|
||||||
|
sortOrder: typeof sortOrder === 'number' ? sortOrder : Number(sortOrder) || 0,
|
||||||
|
},
|
||||||
|
select: memberSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(member);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/team-members/:id
|
||||||
|
router.patch('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const existing = await prisma.teamMember.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!existing) throw new AppError(404, '팀원을 찾을 수 없습니다.');
|
||||||
|
|
||||||
|
const { name, rank, role, cell, contact, photoUrl, sortOrder, isActive } =
|
||||||
|
req.body as Record<string, unknown>;
|
||||||
|
|
||||||
|
const member = await prisma.teamMember.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: {
|
||||||
|
...(name !== undefined && { name: String(name).trim() }),
|
||||||
|
...(rank !== undefined && { rank: rank ? String(rank) : null }),
|
||||||
|
...(role !== undefined && { role: role ? String(role) : null }),
|
||||||
|
...(cell !== undefined && { cell: cell ? String(cell) : null }),
|
||||||
|
...(contact !== undefined && { contact: contact ? String(contact) : null }),
|
||||||
|
...(photoUrl !== undefined && { photoUrl: photoUrl ? String(photoUrl) : null }),
|
||||||
|
...(sortOrder !== undefined && { sortOrder: Number(sortOrder) || 0 }),
|
||||||
|
...(isActive !== undefined && { isActive: isActive === true || isActive === 'true' }),
|
||||||
|
},
|
||||||
|
select: memberSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(member);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/team-members/:id — soft delete
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const existing = await prisma.teamMember.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!existing) throw new AppError(404, '팀원을 찾을 수 없습니다.');
|
||||||
|
|
||||||
|
await prisma.teamMember.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
1
data/postgres/PG_VERSION
Normal file
1
data/postgres/PG_VERSION
Normal file
@@ -0,0 +1 @@
|
|||||||
|
16
|
||||||
BIN
data/postgres/base/1/112
Normal file
BIN
data/postgres/base/1/112
Normal file
Binary file not shown.
BIN
data/postgres/base/1/113
Normal file
BIN
data/postgres/base/1/113
Normal file
Binary file not shown.
BIN
data/postgres/base/1/1247
Normal file
BIN
data/postgres/base/1/1247
Normal file
Binary file not shown.
BIN
data/postgres/base/1/1247_fsm
Normal file
BIN
data/postgres/base/1/1247_fsm
Normal file
Binary file not shown.
BIN
data/postgres/base/1/1247_vm
Normal file
BIN
data/postgres/base/1/1247_vm
Normal file
Binary file not shown.
BIN
data/postgres/base/1/1249
Normal file
BIN
data/postgres/base/1/1249
Normal file
Binary file not shown.
BIN
data/postgres/base/1/1249_fsm
Normal file
BIN
data/postgres/base/1/1249_fsm
Normal file
Binary file not shown.
BIN
data/postgres/base/1/1249_vm
Normal file
BIN
data/postgres/base/1/1249_vm
Normal file
Binary file not shown.
BIN
data/postgres/base/1/1255
Normal file
BIN
data/postgres/base/1/1255
Normal file
Binary file not shown.
BIN
data/postgres/base/1/1255_fsm
Normal file
BIN
data/postgres/base/1/1255_fsm
Normal file
Binary file not shown.
BIN
data/postgres/base/1/1255_vm
Normal file
BIN
data/postgres/base/1/1255_vm
Normal file
Binary file not shown.
BIN
data/postgres/base/1/1259
Normal file
BIN
data/postgres/base/1/1259
Normal file
Binary file not shown.
BIN
data/postgres/base/1/1259_fsm
Normal file
BIN
data/postgres/base/1/1259_fsm
Normal file
Binary file not shown.
BIN
data/postgres/base/1/1259_vm
Normal file
BIN
data/postgres/base/1/1259_vm
Normal file
Binary file not shown.
0
data/postgres/base/1/1417
Normal file
0
data/postgres/base/1/1417
Normal file
0
data/postgres/base/1/1418
Normal file
0
data/postgres/base/1/1418
Normal file
BIN
data/postgres/base/1/14928
Normal file
BIN
data/postgres/base/1/14928
Normal file
Binary file not shown.
BIN
data/postgres/base/1/14928_fsm
Normal file
BIN
data/postgres/base/1/14928_fsm
Normal file
Binary file not shown.
BIN
data/postgres/base/1/14928_vm
Normal file
BIN
data/postgres/base/1/14928_vm
Normal file
Binary file not shown.
0
data/postgres/base/1/14931
Normal file
0
data/postgres/base/1/14931
Normal file
BIN
data/postgres/base/1/14932
Normal file
BIN
data/postgres/base/1/14932
Normal file
Binary file not shown.
BIN
data/postgres/base/1/14933
Normal file
BIN
data/postgres/base/1/14933
Normal file
Binary file not shown.
BIN
data/postgres/base/1/14933_fsm
Normal file
BIN
data/postgres/base/1/14933_fsm
Normal file
Binary file not shown.
BIN
data/postgres/base/1/14933_vm
Normal file
BIN
data/postgres/base/1/14933_vm
Normal file
Binary file not shown.
0
data/postgres/base/1/14936
Normal file
0
data/postgres/base/1/14936
Normal file
BIN
data/postgres/base/1/14937
Normal file
BIN
data/postgres/base/1/14937
Normal file
Binary file not shown.
BIN
data/postgres/base/1/14938
Normal file
BIN
data/postgres/base/1/14938
Normal file
Binary file not shown.
BIN
data/postgres/base/1/14938_fsm
Normal file
BIN
data/postgres/base/1/14938_fsm
Normal file
Binary file not shown.
BIN
data/postgres/base/1/14938_vm
Normal file
BIN
data/postgres/base/1/14938_vm
Normal file
Binary file not shown.
0
data/postgres/base/1/14941
Normal file
0
data/postgres/base/1/14941
Normal file
BIN
data/postgres/base/1/14942
Normal file
BIN
data/postgres/base/1/14942
Normal file
Binary file not shown.
BIN
data/postgres/base/1/14943
Normal file
BIN
data/postgres/base/1/14943
Normal file
Binary file not shown.
BIN
data/postgres/base/1/14943_fsm
Normal file
BIN
data/postgres/base/1/14943_fsm
Normal file
Binary file not shown.
BIN
data/postgres/base/1/14943_vm
Normal file
BIN
data/postgres/base/1/14943_vm
Normal file
Binary file not shown.
0
data/postgres/base/1/14946
Normal file
0
data/postgres/base/1/14946
Normal file
BIN
data/postgres/base/1/14947
Normal file
BIN
data/postgres/base/1/14947
Normal file
Binary file not shown.
BIN
data/postgres/base/1/174
Normal file
BIN
data/postgres/base/1/174
Normal file
Binary file not shown.
BIN
data/postgres/base/1/175
Normal file
BIN
data/postgres/base/1/175
Normal file
Binary file not shown.
BIN
data/postgres/base/1/2187
Normal file
BIN
data/postgres/base/1/2187
Normal file
Binary file not shown.
0
data/postgres/base/1/2224
Normal file
0
data/postgres/base/1/2224
Normal file
BIN
data/postgres/base/1/2228
Normal file
BIN
data/postgres/base/1/2228
Normal file
Binary file not shown.
0
data/postgres/base/1/2328
Normal file
0
data/postgres/base/1/2328
Normal file
0
data/postgres/base/1/2336
Normal file
0
data/postgres/base/1/2336
Normal file
BIN
data/postgres/base/1/2337
Normal file
BIN
data/postgres/base/1/2337
Normal file
Binary file not shown.
BIN
data/postgres/base/1/2579
Normal file
BIN
data/postgres/base/1/2579
Normal file
Binary file not shown.
BIN
data/postgres/base/1/2600
Normal file
BIN
data/postgres/base/1/2600
Normal file
Binary file not shown.
BIN
data/postgres/base/1/2600_fsm
Normal file
BIN
data/postgres/base/1/2600_fsm
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user