EENE Dashboard upload to Gitea

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-06-17 16:59:34 +09:00
parent cf72281c6d
commit b3f2da203b
138 changed files with 13013 additions and 1929 deletions

17
backend/.env.example Normal file
View 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

View File

@@ -8,6 +8,7 @@
"name": "eene-dashboard-backend",
"version": "1.0.0",
"dependencies": {
"@ohah/hwpjs": "^0.1.0-rc.10",
"@prisma/client": "^6.0.0",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
@@ -17,6 +18,7 @@
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"prisma": "^6.0.0",
"socket.io": "^4.8.0",
"uuid": "^10.0.0"
},
@@ -29,11 +31,44 @@
"@types/multer": "^1.4.12",
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",
"prisma": "^6.0.0",
"tsx": "^4.19.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": {
"version": "0.28.0",
"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_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": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
@@ -522,7 +714,6 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
@@ -535,14 +726,12 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -556,14 +745,12 @@
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.3",
@@ -575,7 +762,6 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.3"
@@ -591,9 +777,18 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"devOptional": true,
"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": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
@@ -990,7 +1185,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
@@ -1048,7 +1242,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
@@ -1073,7 +1266,6 @@
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"consola": "^3.2.3"
@@ -1088,6 +1280,15 @@
"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": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1113,14 +1314,12 @@
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
@@ -1204,7 +1403,6 @@
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
@@ -1214,7 +1412,6 @@
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/delegates": {
@@ -1236,7 +1433,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/destroy": {
@@ -1303,7 +1499,6 @@
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -1320,7 +1515,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -1525,14 +1719,12 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/fast-check": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
"devOptional": true,
"funding": [
{
"type": "individual",
@@ -1703,7 +1895,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
@@ -1905,7 +2096,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -2254,7 +2444,6 @@
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"devOptional": true,
"license": "MIT"
},
"node_modules/nopt": {
@@ -2289,7 +2478,6 @@
"version": "0.6.6",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz",
"integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.2.2",
@@ -2307,7 +2495,6 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/object-assign": {
@@ -2335,7 +2522,6 @@
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/on-finished": {
@@ -2396,21 +2582,18 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/pkg-types": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.4",
@@ -2422,7 +2605,6 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2467,7 +2649,6 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
"devOptional": true,
"funding": [
{
"type": "individual",
@@ -2523,7 +2704,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"defu": "^6.1.4",
@@ -2555,7 +2735,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
@@ -2956,7 +3135,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
"integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -2977,6 +3155,13 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"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": {
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",

View File

@@ -4,7 +4,8 @@
"description": "EENE 인재성장팀 대시보드 - Backend API",
"main": "dist/index.js",
"scripts": {
"dev": "npm run db:sync && 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",
"build": "prisma generate && tsc",
"start": "npm run db:sync && node dist/index.js",
@@ -12,13 +13,17 @@
"db:generate": "prisma generate",
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.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:sync-remote": "tsx scripts/sync-from-remote.ts",
"db:push-remote": "tsx scripts/sync-to-remote.ts",
"db:push-photos": "tsx scripts/sync-to-remote.ts --photos-only",
"db:migrate-sections": "tsx scripts/migrate-sections.ts"
"db:migrate-sections": "tsx scripts/migrate-sections.ts",
"db:migrate-milestone-period-notes": "tsx scripts/migrate-milestone-period-notes.ts"
},
"dependencies": {
"@ohah/hwpjs": "^0.1.0-rc.10",
"@prisma/client": "^6.0.0",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",

View File

@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import type { Priority, TaskStatus } from '@prisma/client';
import { getHrSeedPath, getUploadDir } from '../src/lib/projectPaths';
export interface HrProject {
idx?: number;
@@ -26,6 +27,21 @@ export interface HrProject {
subPhases?: { name: string; status?: string; text?: string }[];
timelineItems?: { startDate?: string; endDate?: string; desc?: string }[];
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 {
@@ -59,6 +75,8 @@ const SECTION_MAP: Record<string, string> = {
: '인사관리',
: '학습성장',
: '운영관리',
: '운영관리',
: '운영관리',
};
const STATUS_MAP: Record<string, TaskStatus> = {
@@ -75,7 +93,7 @@ const PHASE_PROGRESS: Record<string, number> = {
};
export function defaultHrDataPath(): string {
return path.resolve(__dirname, '../../../HR_Dashboard/data.json');
return getHrSeedPath();
}
export function loadHrProjects(filePath = defaultHrDataPath()): HrProject[] {
@@ -84,6 +102,72 @@ export function loadHrProjects(filePath = defaultHrDataPath()): HrProject[] {
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 {
if (!value?.trim()) return null;
const d = new Date(value);
@@ -94,6 +178,14 @@ function mapSection(category: string): string {
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 {
if (!status?.trim()) return isRoutine ? 'IN_PROGRESS' : 'TODO';
return STATUS_MAP[status] ?? 'IN_PROGRESS';
@@ -186,8 +278,8 @@ export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTas
status: mapStatus(p.status, isRoutine),
priority: mapPriority(p.priority),
quarter,
category: mapSection(p.category),
section: mapSection(p.category),
category: mapBoardSection(p),
section: mapBoardSection(p),
taskType,
progress: mapProgress(p.progress),
issueNote: pickIssueNote(p),
@@ -204,5 +296,7 @@ export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTas
}
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));
}

View File

@@ -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 $$;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "tasks" ADD COLUMN "issueEntries" JSONB;

View File

@@ -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")
);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "milestones" ADD COLUMN IF NOT EXISTS "subtitle" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "milestones" ADD COLUMN "periodEntries" JSONB;

View File

@@ -46,6 +46,8 @@ model TeamMember {
pmTasks Task[] @relation("PmTasks")
taskAssignees TaskAssignee[]
milestonePmTasks Milestone[] @relation("MilestonePm")
milestoneAssignees MilestoneAssignee[]
@@index([cell])
@@index([isActive])
@@ -72,7 +74,8 @@ model Task {
tag String? // Growth | Policy | Performance | Culture | Asset | Space | Safety | Environment
taskType String? // 상시업무 | 프로젝트
progress Int @default(0)
issueNote String?
issueNote String?
issueEntries Json?
startDate DateTime?
dueDate DateTime?
showDate Boolean @default(true)
@@ -200,24 +203,42 @@ model Milestone {
id String @id @default(cuid())
taskId String
title String
subtitle String?
description String?
startDate DateTime?
dueDate DateTime?
periodEntries Json?
progress Int @default(0)
links String? // JSON: [{ "label": string, "url": string }]
completedAt DateTime?
order Int @default(0)
pmMemberId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
details TaskDetail[]
files File[]
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
pmMember TeamMember? @relation("MilestonePm", fields: [pmMemberId], references: [id])
milestoneAssignees MilestoneAssignee[]
details TaskDetail[]
files File[]
@@index([taskId])
@@index([pmMemberId])
@@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 {
@@ -231,6 +252,16 @@ model ColumnConfig {
@@map("column_configs")
}
// ─── 허브 설정 (분기 중점 과제·일정·상시 라벨) ─────────────────
model HubConfig {
id String @id @default("default")
config Json
updatedAt DateTime @updatedAt
@@map("hub_configs")
}
// ─── 감사 로그 ───────────────────────────────────────────────
model AuditLog {

View File

@@ -1,67 +1,273 @@
import 'dotenv/config';
import bcrypt from 'bcrypt';
import { PrismaClient } from '@prisma/client';
import { mapAllHrProjects } from './mapHrProjects';
import {
loadHrProjects,
mapAllHrProjects,
mapHrProjectToTask,
mapHrTeamMembers,
normalizePersonName,
} from './mapHrProjects';
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() {
console.log('🌱 Seeding database...');
console.log('🌱 Seeding database from data/seed/hr-data.json ...');
const adminPw = await bcrypt.hash('admin1234!', 12);
const memberPw = await bcrypt.hash('member1234!', 12);
const admin = await prisma.user.upsert({
where: { email: 'admin@eene.com' },
update: {},
create: { email: 'admin@eene.com', password: adminPw, name: '관리자', role: 'ADMIN', department: 'EENE' },
});
const member = await prisma.user.upsert({
where: { email: 'member@eene.com' },
update: { name: '정성호' },
create: { email: 'member@eene.com', password: memberPw, name: '정성호', role: 'MEMBER', department: 'EENE' },
});
console.log('✅ Users ready');
const mapped = mapAllHrProjects();
await prisma.milestoneAssignee.deleteMany({});
await prisma.taskAssignee.deleteMany({});
await prisma.file.deleteMany({});
await prisma.taskDetail.deleteMany({});
await prisma.milestone.deleteMany({});
await prisma.kpiMetric.deleteMany({});
await prisma.task.deleteMany({});
for (const t of mapped) {
const { milestones, detailContent, ...taskData } = t;
const task = await prisma.task.create({
await prisma.teamMember.deleteMany({});
const teamParsed = mapHrTeamMembers();
const memberIdByName = new Map<string, string>();
for (const tm of teamParsed) {
const created = await prisma.teamMember.create({
data: {
...taskData,
creatorId: admin.id,
assigneeId: member.id,
name: tm.name,
rank: tm.rank,
role: tm.role,
cell: tm.cell,
photoUrl: tm.photoUrl,
sortOrder: tm.sortOrder,
isActive: true,
},
});
for (const [order, ms] of milestones.entries()) {
await prisma.milestone.create({
data: { ...ms, taskId: task.id, order },
});
}
memberIdByName.set(normalizePersonName(tm.name), created.id);
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(' → 브라우저에서 Ctrl+F5 후, 필요 시 DevTools에서 localStorage 허브 키 삭제');
}
main().catch(console.error).finally(() => prisma.$disconnect());

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

View File

@@ -102,7 +102,7 @@ async function importViaApi(adminId: string, memberId: string) {
async function main() {
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 memberId: string;

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

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

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

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

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

View File

@@ -17,8 +17,11 @@ app.use(
const allowedOrigins = [
'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',
'https://172.16.8.248:3000',
'https://eene-dashboard.vercel.app',
process.env.FRONTEND_URL,
].filter(Boolean) as string[];
@@ -26,11 +29,11 @@ const allowedOrigins = [
function isAllowedOrigin(origin: string): boolean {
if (allowedOrigins.includes(origin)) return true;
if (/^https:\/\/[\w-]+\.vercel\.app$/.test(origin)) return true;
// 로컬·사설망 프론트 (용량 절약용 로컬 서버)
if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return true;
if (/^http:\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
if (/^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
if (/^http:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/.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;
}

View File

@@ -5,6 +5,7 @@ import app from './app';
import { setupSocketHandlers } from './socket';
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 FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
@@ -19,11 +20,16 @@ const io = new Server(httpServer, {
});
setupSocketHandlers(io);
app.set('io', io);
async function main() {
ensureLocalDirs();
await prisma.$connect();
console.log('✅ Database connected (PostgreSQL — 로컬 data/postgres 또는 DATABASE_URL)');
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', () => {
console.log(`✅ Server running on http://0.0.0.0:${PORT} (팀원: http://<이PC의IP>:${PORT})`);

View File

@@ -1,22 +1,6 @@
import fs from 'fs';
import path from 'path';
import { ensureProjectDataDirs } from './projectPaths';
/** 로컬 uploads·팀 사진 폴더 생성 (데이터 영구 저장) */
/** 로컬 data·uploads 폴더 생성 (DB·파일 영구 저장) */
export function ensureLocalDirs() {
const uploadDir = path.resolve(process.env.UPLOAD_DIR || '../uploads');
const teamDir = path.join(uploadDir, 'team');
const dataPostgresHint = path.resolve('../data/postgres');
for (const dir of [uploadDir, teamDir]) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(`📁 Created: ${dir}`);
}
}
if (!fs.existsSync(dataPostgresHint)) {
console.log(
'💡 PostgreSQL 로컬 저장: 프로젝트 루트에서 docker compose up -d 실행 시 data/postgres 에 DB가 보존됩니다.',
);
}
ensureProjectDataDirs();
}

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

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

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

View File

@@ -0,0 +1,47 @@
export interface TaskIssueEntry {
id: string;
text: string;
showOnCard: boolean;
}
function newIssueId() {
return `issue-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
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,
});
}
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 }];
}
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,
};
}

View File

@@ -11,6 +11,13 @@ export const teamMemberSelect = {
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 } },
@@ -34,15 +41,32 @@ export const taskDetailInclude = {
},
kpiMetrics: true,
files: true,
milestones: { orderBy: { order: 'asc' as const } },
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, ...rest } = task as T & {
const { taskAssignees, milestones, ...rest } = task as T & {
taskAssignees?: Array<{ member: unknown }>;
milestones?: Array<Record<string, unknown>>;
};
const assigneeMembers = (taskAssignees ?? []).map((ta) => ta.member);
return { ...rest, assigneeMembers };
const formattedMilestones = milestones?.map((m) => formatMilestone(m));
return {
...rest,
assigneeMembers,
...(formattedMilestones !== undefined ? { milestones: formattedMilestones } : {}),
};
}
export async function syncTaskMembers(

View 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 */
}
}

View File

@@ -1,33 +1,49 @@
import type { Request, Response, NextFunction } from 'express';
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
) {
super(message);
this.name = 'AppError';
}
}
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction,
): void {
if (err instanceof AppError) {
res.status(err.statusCode).json({ message: err.message });
return;
}
console.error('[Error]', err);
const prismaCode = (err as { code?: string }).code;
if (prismaCode === 'P2022') {
res.status(500).json({ message: 'DB 스키마가 최신이 아닙니다. 배포 후 다시 시도해 주세요.' });
return;
}
res.status(500).json({ message: '서버 오류가 발생했습니다.' });
}
import type { Request, Response, NextFunction } from 'express';
import { DISK_FULL_MESSAGE, isDiskFullError } from '../lib/uploadErrors';
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
) {
super(message);
this.name = 'AppError';
}
}
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction,
): void {
if (err instanceof AppError) {
res.status(err.statusCode).json({ message: err.message });
return;
}
if (isDiskFullError(err)) {

View File

@@ -3,7 +3,6 @@ import path from 'path';
import { v4 as uuidv4 } from 'uuid';
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');
if (!fs.existsSync(UPLOAD_DIR)) {
@@ -20,7 +19,5 @@ const storage = multer.diskStorage({
},
});
export const upload = multer({
storage,
limits: { fileSize: MAX_SIZE_MB * 1024 * 1024 },
});
/** 파일 크기 상한 없음 — 디스크 여유만큼 저장 (부족 시 uploadErrors) */
export const upload = multer({ storage });

View File

@@ -22,7 +22,6 @@ const storage = multer.diskStorage({
export const uploadTeamPhoto = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter(_req, file, cb) {
if (/^image\/(jpeg|jpg|png|gif|webp)$/i.test(file.mimetype)) {
cb(null, true);

View File

@@ -10,8 +10,8 @@ const DEFAULTS: Record<string, { title: string; titleEn: string; subtitle: strin
subtitle: '임직원의 몰입(Engagement)과 성장(Education)',
},
'운영관리': {
title: '운영관리 부문',
titleEn: 'Operations',
title: '총무관리',
titleEn: 'GA',
subtitle: '인프라 고도화와 자산 라이프사이클 표준화',
},
};
@@ -27,6 +27,18 @@ router.get('/:key', async (req, res, next) => {
config = await prisma.columnConfig.create({
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);

View File

@@ -1,20 +1,43 @@
import { Router, type Response } from 'express';
import { Router, type Request, type Response } from 'express';
import path from 'path';
import fs from 'fs';
import { prisma } from '../lib/prisma';
import { resolveTaskActorId } from '../lib/resolveUser';
import { upload } from '../middleware/upload';
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();
/** Vercel 상세 창에서 PDF 등 iframe 미리보기 허용 */
function allowCrossOriginPreview(res: Response) {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader(
'Content-Security-Policy',
"frame-ancestors 'self' https://eene-dashboard.vercel.app https://*.vercel.app http://localhost:3000",
const PREVIEW_FRAME_ANCESTORS = [
"'self'",
'https://eene-dashboard.vercel.app',
'http://localhost:3000',
'https://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');
}
@@ -59,7 +82,7 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) =>
originalName: fixOriginalName(req.file.originalname),
displayName,
sortOrder: Number.isNaN(sortOrder) ? 0 : sortOrder,
mimetype: req.file.mimetype,
mimetype: resolveFileMime(fixOriginalName(req.file.originalname), req.file.mimetype),
size: req.file.size,
path: req.file.path,
uploadedBy,
@@ -68,10 +91,47 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) =>
res.status(201).json(fileRecord);
} catch (err) {
if (isDiskFullError(err)) {
cleanupUploadedFile(req.file);
next(new AppError(507, DISK_FULL_MESSAGE));
return;
}
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 — 파일 미리보기 (브라우저에서 바로 열기)
router.get('/:id/view', async (req, res, next) => {
try {
@@ -80,8 +140,9 @@ router.get('/:id/view', async (req, res, next) => {
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
allowCrossOriginPreview(res);
res.setHeader('Content-Type', file.mimetype);
allowCrossOriginPreview(req, res);
const mime = resolveFileMime(file.originalName, file.mimetype);
res.setHeader('Content-Type', mime);
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.originalName)}"`);
fs.createReadStream(file.path).pipe(res);
} catch (err) {
@@ -122,7 +183,7 @@ router.post('/:id/replace', upload.single('file'), async (req, res, next) => {
data: {
filename: req.file.filename,
originalName: fixOriginalName(req.file.originalname),
mimetype: req.file.mimetype,
mimetype: resolveFileMime(fixOriginalName(req.file.originalname), req.file.mimetype),
size: req.file.size,
path: req.file.path,
},
@@ -130,6 +191,11 @@ router.post('/:id/replace', upload.single('file'), async (req, res, next) => {
res.json(file);
} catch (err) {
if (isDiskFullError(err)) {
cleanupUploadedFile(req.file);
next(new AppError(507, DISK_FULL_MESSAGE));
return;
}
next(err);
}
});

View File

@@ -0,0 +1,74 @@
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 normalizeConfig(raw: Record<string, unknown>) {
const sloganTitle = (raw.sloganTitle as string) ?? DEFAULT_HUB_CONFIG.sloganTitle;
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: Array.isArray(raw.routineLabels)
? (raw.routineLabels as string[])
: DEFAULT_HUB_CONFIG.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;

View File

@@ -8,6 +8,7 @@ import columnRoutes from './columns';
import milestoneRoutes from './milestones';
import detailRoutes from './details';
import teamMemberRoutes from './teamMembers';
import hubConfigRoutes from './hubConfig';
const router = Router();
@@ -18,6 +19,7 @@ router.use('/users', userRoutes);
router.use('/files', fileRoutes);
router.use('/kpi', kpiRoutes);
router.use('/columns', columnRoutes);
router.use('/hub-config', hubConfigRoutes);
router.use('/milestones', milestoneRoutes);
router.use('/details', detailRoutes);

View File

@@ -1,6 +1,9 @@
import { Router } from 'express';
import { Prisma } from '@prisma/client';
import { prisma } from '../lib/prisma';
import { resolveTaskActorId } from '../lib/resolveUser';
import { formatMilestone, milestoneInclude, parseMemberIds } from '../lib/taskQuery';
import { resolveMilestonePeriodPayload } from '../lib/milestonePeriods';
import { AppError } from '../middleware/errorHandler';
const router = Router();
@@ -28,14 +31,47 @@ function clampProgress(value: unknown): number {
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
router.get('/:taskId', async (req, res, next) => {
try {
const milestones = await prisma.milestone.findMany({
where: { taskId: req.params.taskId },
orderBy: { order: 'asc' },
include: milestoneInclude,
});
res.json(milestones);
res.json(milestones.map((m) => formatMilestone(m)));
} catch (err) {
next(err);
}
@@ -45,26 +81,48 @@ router.get('/:taskId', async (req, res, next) => {
router.post('/:taskId', async (req, res, next) => {
try {
const taskId = req.params.taskId;
const { title, description, startDate, dueDate, feedback, links, progress } =
req.body as Record<string, string | number>;
const body = req.body as Record<string, unknown>;
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, '단계 제목은 필수입니다.');
const count = await prisma.milestone.count({ where: { taskId } });
const periodPayload = resolveMilestonePeriodPayload(body);
const milestone = await prisma.milestone.create({
data: {
taskId,
title: String(title).trim(),
subtitle: subtitle !== undefined ? String(subtitle || '').trim() || null : null,
description: description?.toString().trim() || null,
startDate: startDate ? new Date(String(startDate)) : null,
dueDate: dueDate ? new Date(String(dueDate)) : null,
startDate:
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,
links: normalizeLinks(links),
order: count,
...(pmMemberId !== undefined ? { pmMemberId } : {}),
},
});
await syncMilestoneMembers(milestone.id, pmMemberId, assigneeMemberIds);
if (feedback?.toString().trim()) {
const updatedBy = await resolveTaskActorId(taskId);
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) {
next(err);
}
@@ -86,19 +144,33 @@ router.post('/:taskId', async (req, res, next) => {
// PATCH /api/milestones/item/:id
router.patch('/item/:id', async (req, res, next) => {
try {
const { title, description, startDate, dueDate, feedback, links, progress, completed, order } =
req.body as Record<string, string | boolean | number>;
const body = req.body as Record<string, unknown>;
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 } });
if (!existing) throw new AppError(404, '단계를 찾을 수 없습니다.');
const periodPayload = resolveMilestonePeriodPayload(body);
const milestone = await prisma.milestone.update({
where: { id: req.params.id },
data: {
...(title !== undefined && { title: String(title).trim() }),
...(subtitle !== undefined && { subtitle: String(subtitle || '').trim() || null }),
...(description !== undefined && { description: description ? String(description).trim() : null }),
...(startDate !== undefined && { startDate: startDate ? new Date(String(startDate)) : null }),
...(dueDate !== undefined && { dueDate: dueDate ? new Date(String(dueDate)) : null }),
...(periodPayload.startDate !== undefined && { startDate: periodPayload.startDate }),
...(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) }),
...(links !== undefined && { links: normalizeLinks(links) }),
...(order !== undefined && { order: Number(order) }),
@@ -106,9 +178,12 @@ router.patch('/item/:id', async (req, res, next) => {
completedAt: completed ? new Date() : null,
...(completed && { progress: 100 }),
}),
...(pmMemberId !== undefined ? { pmMemberId } : {}),
},
});
await syncMilestoneMembers(milestone.id, pmMemberId, assigneeMemberIds);
if (typeof feedback === 'string' && feedback.trim()) {
const updatedBy = await resolveTaskActorId(existing.taskId);
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) {
next(err);
}

View File

@@ -1,7 +1,9 @@
import { Router } from 'express';
import type { Server } from 'socket.io';
import { prisma } from '../lib/prisma';
import { resolveCreatorId } from '../lib/resolveUser';
import { AppError } from '../middleware/errorHandler';
import { emitTaskListRefresh, emitTaskUpdated } from '../socket';
import {
formatTask,
parseMemberIds,
@@ -9,9 +11,25 @@ import {
taskDetailInclude,
taskInclude,
} from '../lib/taskQuery';
import { deriveIssueFields, normalizeIssueEntries } from '../lib/taskIssues';
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)
router.get('/', async (req, res, next) => {
try {
@@ -64,6 +82,7 @@ router.post('/', async (req, res, next) => {
const creatorId = await resolveCreatorId(body.creatorId);
const assigneeMemberIds = parseMemberIds(body);
const issuePayload = resolveIssuePayload(body);
const task = await prisma.task.create({
data: {
@@ -77,13 +96,14 @@ router.post('/', async (req, res, next) => {
tag,
taskType,
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,
dueDate: dueDate ? new Date(dueDate) : undefined,
showDate: showDate !== undefined ? showDate === 'true' || showDate === true : true,
showDescription: showDescription !== undefined ? showDescription === 'true' || showDescription === 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,
assigneeId: assigneeId || null,
pmMemberId: pmMemberId || null,
@@ -120,6 +140,7 @@ router.patch('/:id', async (req, res, next) => {
showDescription, showStatus, showIssue, showProgress, pmMemberId } = body;
const assigneeMemberIds = parseMemberIds(body);
const issuePayload = resolveIssuePayload(body);
await prisma.task.update({
where: { id: req.params.id },
@@ -134,7 +155,20 @@ router.patch('/:id', async (req, res, next) => {
...(tag !== undefined && { tag }),
...(taskType !== undefined && { taskType }),
...(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 }),
...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate) : null }),
...(assigneeId !== undefined && { assigneeId: assigneeId || null }),
@@ -160,7 +194,14 @@ router.patch('/:id', async (req, res, next) => {
include: taskInclude,
});
res.json(formatTask(task!));
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) {
next(err);
}

View File

@@ -2,6 +2,7 @@ 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();
@@ -43,6 +44,11 @@ router.post('/photo', uploadTeamPhoto.single('photo'), async (req, res, next) =>
filename: req.file.filename,
});
} catch (err) {
if (isDiskFullError(err)) {
cleanupUploadedFile(req.file);
next(new AppError(507, DISK_FULL_MESSAGE));
return;
}
next(err);
}
});