From c6f342a861aa6cc9d2028232ecfb05015e8e5452 Mon Sep 17 00:00:00 2001 From: unknown <0420301@BOKNET.INTRA> Date: Thu, 16 Jul 2026 11:00:09 +0900 Subject: [PATCH] feat: add Node ACS admin workflows --- docs/ACS-login-404-analysis.md | 12 +- docs/ACS작업일지.md | 252 +++++++ frontend/src/App.tsx | 9 + frontend/src/api.ts | 52 ++ frontend/src/components/Layout.tsx | 3 +- .../components/VisitRequestDetailDialog.tsx | 162 ++++ frontend/src/pages/AccessConsolePage.tsx | 59 +- frontend/src/pages/AdminManagementPage.tsx | 584 +++++++++++++++ frontend/src/pages/AuditLogPage.tsx | 2 + frontend/src/pages/DashboardPage.tsx | 41 +- frontend/src/pages/LoginPage.tsx | 1 + frontend/src/pages/ReportPage.tsx | 76 +- frontend/src/pages/VisitRequestFormPage.tsx | 84 ++- frontend/src/styles/common.css | 126 +++- frontend/src/types.ts | 64 +- migrations/006_admin_config.sql | 49 ++ migrations/007_teams.sql | 43 ++ package.json | 2 + server/routes/admin.ts | 682 +++++++++++++++++ server/routes/auth.test.ts | 195 +++++ server/routes/auth.ts | 380 ++++------ server/routes/business.ts | 706 ++++++++++++++++++ server/routes/index.ts | 5 + server/routes/security.ts | 119 +++ 24 files changed, 3442 insertions(+), 266 deletions(-) create mode 100644 frontend/src/components/VisitRequestDetailDialog.tsx create mode 100644 frontend/src/pages/AdminManagementPage.tsx create mode 100644 migrations/006_admin_config.sql create mode 100644 migrations/007_teams.sql create mode 100644 server/routes/admin.ts create mode 100644 server/routes/auth.test.ts create mode 100644 server/routes/business.ts create mode 100644 server/routes/security.ts diff --git a/docs/ACS-login-404-analysis.md b/docs/ACS-login-404-analysis.md index 6410e0a..5f1b8a8 100644 --- a/docs/ACS-login-404-analysis.md +++ b/docs/ACS-login-404-analysis.md @@ -68,8 +68,14 @@ Node 서버에 Auth API를 추가했다. 판단: 라우터는 배포에 반영되었고, 남은 문제는 운영 DB의 사용자 seed 또는 로그인 처리 중 DB 상태 문제로 좁혀졌다. 다음 배포부터 테스트 계정이 자동 보장되도록 `migrations/005_seed_test_users.sql`을 추가한다. -2026-07-14 after Coolify redeploy, `/api/health` returned `build: auth-seed-20260714`. +2026-07-14 원격 `main`에 `e02afcd Add auth deployment diagnostics`까지 push했다. -However, `/api/auth/diagnostics` reported no `users` or `user_roles` table, and login still returned 500. +확인 결과 `GET /api/health`가 아직 `{"status":"UP","service":"acs-node"}`만 반환하고, 새 build marker인 `auth-seed-20260714`를 반환하지 않았다. -Conclusion: runtime migration is not guaranteed when Coolify overrides Dockerfile CMD. Added startup migration in `server/index.ts` so `runMigrations()` executes before the Express server starts. +판단: 최신 커밋이 아직 운영 컨테이너에 반영되지 않았다. Coolify에서 수동 Redeploy 또는 배포 로그 확인이 필요하다. + +2026-07-14 Coolify Redeploy 후 `/api/health`에서 `build: auth-seed-20260714` 확인. + +다만 `/api/auth/diagnostics`에서 `users`, `user_roles` 테이블이 확인되지 않아 로그인은 500으로 계속 실패했다. + +판단: Coolify 실행 명령이 Dockerfile의 `CMD ["npm", "run", "start:deploy"]`를 타지 않으면 migration이 실행되지 않을 수 있다. 이를 보완하기 위해 서버 시작 시 `runMigrations()`를 직접 실행하도록 `server/index.ts`에 startup migration을 추가한다. diff --git a/docs/ACS작업일지.md b/docs/ACS작업일지.md index 6f675cc..5fc3542 100644 --- a/docs/ACS작업일지.md +++ b/docs/ACS작업일지.md @@ -384,6 +384,159 @@ - `GET /db`는 현재 Windows 로컬에 `DATABASE_URL`이 없어 500과 명확한 오류 메시지를 반환. AI DEV/Coder에서 `.project-env` 로드 후 재검증 필요. - `docker build -t acs-node-guide-check .`는 Docker daemon 미기동(`dockerDesktopLinuxEngine` pipe 없음)으로 실행 전 실패. Docker Desktop 또는 AI DEV/Coder 배포 환경에서 재검증 필요. +### Coder / Gitea / Coolify 배포 검증 +- Coder 접속: + - `https://coder.bokdev.in/workspaces`에서 `ws-aidev-0420301` 워크스페이스 확인. + - 최초 VS Code Web 접속 시 `Agent state is "disconnected"` 404가 발생했으나, 잠시 대기 후 VS Code Web 아이콘이 활성화되어 접속 성공. + - VS Code Web 작업 위치: `/home/coder/projects`. +- Gitea 장애: + - 초기에는 Gitea 루트는 200이었으나 저장소 URL은 500 오류: + - `https://gitea.bokdev.in/playground/MANUAL` + - `https://gitea.bokdev.in/playground/acs` + - `https://gitea.bokdev.in/0420301/acs` + - `git ls-remote https://gitea.bokdev.in/playground/acs.git` + - 인프라 확인 후 Gitea가 정상화되어 clone 가능해짐. +- AI DEV 표준 프로젝트 생성: + - 단순 `git clone https://gitea.bokdev.in/playground/acs.git`만 수행하면 `.project-env`가 생성되지 않아 `DATABASE_URL`이 없음. + - `docs/AIdev.md`의 6번 절차에 따라 아래 순서로 표준 프로젝트 재생성: + - `mv acs acs-from-git` + - `new-project acs` + - `cd ~/projects/acs` + - `.project-env` 생성 확인. + - 이후 원격 소스 반영: + - `git init -b main` + - `git remote add origin https://gitea.bokdev.in/playground/acs.git` + - `git fetch origin` + - `git reset --hard origin/main` + - `.project-env`가 유지되었고 `DATABASE_URL` 환경변수 로드 확인. +- Coder 로컬 검증: + - `npm install` 성공. + - `npm --prefix frontend install` 성공. + - `npm run typecheck` 성공. + - `npm run db:check` 성공: + - `DB OK: Mon Jul 13 2026 10:53:51 GMT+0900 (한국 표준시)` + - `npm run minio:check`는 ACS S3 미사용으로 정상 skip: + - `S3 SKIPPED: ACS does not currently use S3/MinIO storage.` + - `npm run build` 성공. + - `npm run migrate` 성공: + - `Applied migration 001_init.sql` + - `Applied migration 002_audit_log.sql` + - `Applied migration 003_pass_delivery.sql` + - `Applied migration 004_visit_request_contact_fields.sql` + - `npm start` 성공: + - `ACS Node app listening on port 3000` + - Coder 로컬 endpoint 확인: + - `curl 127.0.0.1:3000/healthz` -> `{"ok":true}` + - `curl 127.0.0.1:3000/db` -> `{"ok":true,"now":"..."}` + - `curl 127.0.0.1:3000/s3` -> ACS S3 미사용 skip 응답. + - Coder 내 Docker/Podman build 성공: + - `docker build -t acs-node-local .` + - `Successfully tagged localhost/acs-node-local:latest` +- Coolify 리소스 생성: + - Coolify 팀/프로젝트: `aidev / production`. + - Resource type: `Public Repository`. + - Repository URL: `https://gitea.bokdev.in/playground/acs.git`. + - Branch: `main`. + - Build Pack: `Dockerfile`. + - Base Directory: `/`. + - Port: `3000`. + - Static site: 비활성. + - 최초 자동 생성 resource 이름: + - `obedient-ocelot-w6wzpkzzv8qua4uzejznirp` + - 최초 자동 생성 domain: + - `https://hridawfl9pktjtzq1vj92zbb.apps.bokdev.in` +- Coolify 환경변수 등록: + - `.project-env`에서 제공된 값 중 아래 DB 관련 변수를 등록: + - `DATABASE_URL` + - `PGHOST` + - `PGPORT` + - `PGDATABASE` + - `PGUSER` + - `PGPASSWORD` + - `PGOPTIONS` + - `PORT` + - 추가 ACS 변수: + - `SESSION_SECRET` + - `ACS_PUBLIC_BASE_URL=https://hridawfl9pktjtzq1vj92zbb.apps.bokdev.in` + - `ACS_SMS_PROVIDER=dev` + - S3 관련 변수는 ACS 현재 범위에서는 사용하지 않으므로 필수 등록 대상에서 제외. +- Coolify healthcheck 설정: + - Type: `HTTP` + - Method: `GET` + - Scheme: `http` + - Host: `localhost` + - Port: `3000` + - Path: `/healthz` + - Return Code: `200` + - Healthcheck enabled. +- 1차 Coolify 배포 결과: + - Docker image build와 container start는 성공. + - 컨테이너 로그에 `ACS Node app listening on port 3000` 확인. + - 그러나 Coolify healthcheck 실패: + - `/bin/sh: 1: curl: not found` + - `/bin/sh: 1: wget: not found` + - 원인: + - `node:22-bookworm-slim` runtime image에 Coolify가 healthcheck에 사용하는 `curl`/`wget`이 없음. +- healthcheck 실패 조치: + - `Dockerfile` runtime stage에 `curl` 및 `ca-certificates` 설치 추가: + - `apt-get update` + - `apt-get install -y --no-install-recommends curl ca-certificates` + - `rm -rf /var/lib/apt/lists/*` + - Coder에서 커밋: + - `Install curl for Coolify healthcheck` + - Coder에서 최초 `git push`는 upstream 미설정으로 실패. + - `git push --set-upstream origin main`은 인증 실패. + - Gitea `Settings > Applications`에서 repository read/write 권한 토큰 생성 후 토큰 URL 방식으로 push 성공. +- 2차 Coolify 배포 결과: + - commit `44a4bc1086ab9d57dd9815e519c2d2c3c2d0d9c4` 기준 배포. + - Docker image build 성공. + - New container started. + - Healthcheck URL: + - `GET http://localhost:3000/healthz` + - Healthcheck passed: + - `Healthcheck status: "healthy"` + - `Return code: 0` + - Rolling update completed. + - Coolify 상태: + - `Running (healthy)` +- 외부 배포 URL 검증: + - `https://hridawfl9pktjtzq1vj92zbb.apps.bokdev.in/healthz` + - `{"ok":true}` + - `https://hridawfl9pktjtzq1vj92zbb.apps.bokdev.in/db` + - `{"ok":true,"now":"2026-07-13T05:12:54.842Z"}` 등 정상 응답. + - `https://hridawfl9pktjtzq1vj92zbb.apps.bokdev.in/login` + - React 로그인 화면 표시 정상. +- 현재 배포 상태: + - AI DEV 표준 Node.js + Dockerfile + `.project-env`/`DATABASE_URL` 기반 배포 파이프라인 성공. + - 현재 Node 서버는 health/db/static frontend 기반까지만 구현됨. + - 기존 ACS 업무 기능은 아직 Spring Boot 백엔드에서 Node API로 이관 전. + - 로그인 화면은 표시되지만 `/api/auth/login` 등 Node Auth API 구현 전이므로 실제 로그인은 다음 단계에서 구현 필요. +- 도메인 정리 및 고정 도메인 전환: + - 최초 Coolify 자동 생성 임시 도메인: + - `https://hridawfl9pktjtzq1vj92zbb.apps.bokdev.in` + - 인프라팀에 고정 도메인 사용 가능 여부 확인 요청: + - 희망: `https://acs.apps.bokdev.in` + - 또는 AI DEV 표준: `https://acs.playground.bokdev.in` + - 팀장 확인 결과, Coolify에서 사용자가 직접 고정 도메인으로 변경하면 되는 것으로 판단. + - Coolify `Configuration > General`에서 `Domains` 값을 아래로 변경: + - `https://acs.apps.bokdev.in` + - `Set Direction`은 기존 direction 확인/적용용이며, domain 자체 저장은 `General` 제목 옆 `Save` 버튼으로 처리. + - 도메인 저장 직후 `/healthz`에서 일시적으로 `no available server`가 표시되었으나, 재배포/라우팅 반영 후 정상화. + - Coolify `Configuration > Environment Variables`에서 `ACS_PUBLIC_BASE_URL` 값 변경: + - 변경 전: `https://hridawfl9pktjtzq1vj92zbb.apps.bokdev.in` + - 변경 후: `https://acs.apps.bokdev.in` + - 변경 후 재배포 및 최종 확인 완료: + - `https://acs.apps.bokdev.in/healthz` -> `{"ok":true}` + - `https://acs.apps.bokdev.in/db` -> `{"ok":true,"now":"..."}` + - `https://acs.apps.bokdev.in/login` -> React 로그인 화면 표시 정상. + - AI DEV 포털 프로젝트의 배포 URL도 `https://acs.apps.bokdev.in`로 맞추는 방향으로 정리. +- 다음 작업: + - Node Auth/session/role middleware 구현. + - PostgreSQL session store 구성. + - 사용자 seed 또는 기존 사용자 적재 방식 확정. + - `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`, `/api/auth/change-password` 구현. + - 이후 방문신청/승인/출입/리포트 API를 순차 이관. + ## 2026-07-14 ### ACS 테스트 준비 및 문서 산출물 @@ -492,3 +645,102 @@ - 한 화면 표시 여부. - 레이블/placeholder/그룹 박스/연락처 자동 포맷 확인. - 방문 신청 저장 API가 아직 Node로 이관되지 않은 경우, 다음 테스트 단계에서 `/api/visit-requests` 구현 필요. + +## 2026-07-16 + +### 출입신청 날짜 검증 및 QR 유효기간 정책 +- 출입신청 화면에서 퇴장일이 출입일 다음날 이후인 경우 2건 분리 신청 안내 후 제출 차단. +- 출입일시가 현재 시스템 일시 이전이어도 입력 가능하도록 과거일자 차단 제거. +- 출입일시가 퇴장일시보다 늦은 경우 안내 메시지 후 제출 차단. +- 백엔드 신청 생성 API에도 동일한 역전/익일 퇴장 검증 추가. +- 방문자 QR은 `visitFrom`의 일자 동안만 유효하도록 입장 판정 기준을 `visitTo`가 아닌 `visitFrom.toLocalDate()`로 변경. +- 지난 출입일 QR을 스캔하면 입장을 차단하고 해당 신청을 `EXPIRED`로 전이하도록 조정. +- 매일 00:10 만료 배치도 `visitFrom` 기준으로 지난 승인 건을 만료 처리하도록 변경. +- 검증: + - `npm.cmd run build` 성공. + - `mvn.cmd test` 성공. + +### 개인정보 동의 문구 조정 +- `[개인정보 수집·이용 동의 확인]`을 `[방문자에 대한 개인정보 수집·이용 동의 확인]`으로 변경. +- 보유·이용 기간 문구를 `전산실 퇴장 등록시 입력된 방문자 이름, 연락처, 이메일, 차량번호는 바로 삭제`로 변경. +- 해당 보유·이용 기간 한 줄만 red 색상으로 표시. +- 검증: + - `npm.cmd run build` 성공. + +### 시스템 관리 및 출입목적 코드화 +- 관리자 전용 `시스템관리` 메뉴 추가. +- 관리 탭: + - 현장감시자1 정보 수정. + - 출입목적 코드/표시명/사용 여부/기타 입력 허용 관리. + - 사용자별 ADMIN/SECURITY/HOST 권한 관리. +- 출입신청 화면의 출입목적 목록을 고정 상수에서 서버 코드 목록 기반으로 변경. +- 방문신청 저장 시 `purposeCode`, `purposeDetail`을 함께 저장하도록 확장. +- 기타 목적 입력값이 기존 출입목적 코드/표시명과 일치하면 해당 코드로 자동 정규화. +- Flyway `V5__admin_config.sql` 추가: + - `purpose_codes` + - `system_settings` + - `visit_requests.purpose_code` + - `visit_requests.purpose_detail` +- 검증: + - `mvn.cmd test` 성공. + - `npm.cmd run build` 성공. + - 로컬 새 API 확인: `/api/purpose-codes`, `/api/admin/purpose-codes`, `/api/settings/watcher1`, `/api/admin/users` 모두 200. + +### 팀 기반 권한관리 개선 +- 개발1팀 등 조직 변경이 잦은 운영을 고려해 사용자별 권한 직접 수정만 두지 않고, 팀 기준 권한 템플릿을 추가. +- `시스템관리 > 팀 관리` 탭 추가: + - 팀 코드, 팀명, 사용 여부, 기본권한 관리. + - 기본권한은 ADMIN/SECURITY/HOST 다중 선택 가능. + - 팀별 기본권한을 해당 팀 소속 전체 사용자에게 일괄 적용 가능. +- `시스템관리 > 권한관리` 탭 개선: + - 사용자 검색, 팀 필터 추가. + - 사용자별 소속 팀 변경 가능. + - 팀 변경 시 팀 기본권한을 즉시 적용할지 선택 가능. + - 특정 사용자에게 팀 기본권한만 별도 재적용 가능. +- 기존 `users.department`는 호환성을 위해 유지하되, 신규 `teams` 마스터와 `users.team_id`를 기준으로 운영하도록 확장. +- Flyway `V6__teams.sql` 추가: + - `teams` + - `team_default_roles` + - `users.team_id` + - 기존 IT운영팀/개발1팀/보안팀 및 기본권한 seed. +- 검증: + - `mvn.cmd test` 성공. + - `npm.cmd run build` 성공. + - 로컬 서버 재기동 후 `/api/admin/teams`, `/api/admin/users` 모두 200. + +### 팀/권한 엑셀 업로드 +- `시스템관리 > 팀 관리`에 엑셀 업로드 기능 추가. + - 컬럼: `팀 코드`, `팀명`, `기본권한`. + - 팀 코드를 기준으로 신규/수정 구분. + - 기본권한은 업로드 값으로 교체. + - 양식 다운로드 제공. +- `시스템관리 > 권한관리`에 엑셀 업로드 기능 추가. + - 컬럼: `아이디`, `이름`, `팀명`, `권한`. + - 기존 사용자만 수정하며 신규 사용자 생성은 제외. + - 아이디 기준으로 사용자 확인, 이름 불일치/없는 팀/잘못된 권한/중복 행은 오류 처리. + - 양식 다운로드 제공. +- 업로드는 즉시 반영하지 않고 `검증`으로 미리보기 결과를 확인한 뒤, 오류가 0건일 때만 `적용` 가능하도록 구성. +- 백엔드에서 Apache POI로 `.xlsx`를 파싱하고 적용 시 `ADMIN_CONFIG_UPDATE` 감사로그 기록. +- 검증: + - `mvn.cmd test` 성공. + - `npm.cmd run build` 성공. + - 로컬 서버 재기동 후 팀 양식 다운로드 및 `/api/admin/teams/upload?dryRun=true` 200 확인. + - 권한 양식 다운로드 및 `/api/admin/users/roles/upload?dryRun=true` 200 확인. + +### 권한관리 운영 방식 일원화 +- `시스템관리 > 권한관리` 하단 목록의 사용자별 `기본권한 적용` 버튼 제거. +- 사용자별 권한 변경은 팀 선택/권한 체크박스 수정 후 `저장` 버튼으로 반영하도록 변경. +- 팀 변경 시에도 팀 기본권한을 자동 적용하지 않고, 관리자가 화면에서 확인한 권한 체크 상태 그대로 저장. +- 팀 기본권한 적용 기능은 `팀 관리`의 팀원 일괄 적용 또는 권한 엑셀 업로드 방식으로만 유지. +- `/api/admin/**`는 기존 보안 설정상 ADMIN 권한자만 접근 가능함을 확인. +- 검증: + - `npm.cmd run build` 성공. + - `mvn.cmd test` 성공. +- 2026-07-16 배포 준비: + - 서버 배포 구조가 루트 Node Dockerfile 기준임을 확인하고, Spring Boot 변경사항 중 서버에서 필요한 기능을 Node API로 이관. + - 원격 최신 `playground/acs` 기준의 클린 클론(`acs-deploy`)에 Node/프론트/마이그레이션 변경만 적용. + - 클린 클론 검증: + - `npm.cmd run typecheck` 성공. + - `npm.cmd run build` 성공. + - `npm.cmd test` 성공. + - 로컬 DB 접속 정보(`DATABASE_URL`)가 없어 신규 마이그레이션은 서버 기동 시 적용되는 방식으로 확인 예정. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 15cbd5d..8512c98 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,7 @@ import { BlacklistPage } from './pages/BlacklistPage'; import { ReportPage } from './pages/ReportPage'; import { AuditLogPage } from './pages/AuditLogPage'; import { DeliveryOutboxPage } from './pages/DeliveryOutboxPage'; +import { AdminManagementPage } from './pages/AdminManagementPage'; /** Requires a logged-in user; optionally one of the given roles. */ const Protected: React.FC<{ roles?: Role[]; children: React.ReactNode }> = ({ roles, children }) => { @@ -99,6 +100,14 @@ export default function App() { } /> + + + + } + /> } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index bbaa355..9380b7d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,6 +1,7 @@ import { AccessAction, AccessRecord, + AdminExcelImportResult, ApiResponse, AuditLog, BlacklistCreate, @@ -12,10 +13,15 @@ import { InsideVisitor, LoginRequest, PassDelivery, + PurposeCode, + Role, StatsSummary, PublicPass, + Team, VisitRequestCreate, VisitRequestView, + AdminUser, + Watcher1Settings, Zone, } from './types'; @@ -85,6 +91,7 @@ export const changePassword = (req: ChangePasswordRequest) => // ===== Zones ===== export const listZones = () => request('/zones'); +export const listPurposeCodes = () => request('/purpose-codes'); // ===== Visit requests ===== export const listVisitRequests = () => @@ -154,6 +161,9 @@ export const deleteBlacklist = (id: number) => request(`/blacklist/${id}`, { method: 'DELETE' }); // ===== Reports ===== +export const listReportVisits = (from: string, to: string) => + request(`/reports/visits?from=${from}&to=${to}`); + export const reportDownloadUrl = (from: string, to: string) => `/api/reports/visits.xlsx?from=${from}&to=${to}`; @@ -165,3 +175,45 @@ export const listDeliveries = (status?: DeliveryStatus) => request(`/admin/deliveries${status ? `?status=${status}` : ''}`); export const retryDelivery = (id: number) => request(`/admin/deliveries/${id}/retry`, { method: 'POST' }); + +// ===== Admin management ===== +export const listAdminPurposeCodes = () => request('/admin/purpose-codes'); +export const createAdminPurposeCode = (payload: Omit) => + request('/admin/purpose-codes', jsonInit('POST', payload)); +export const updateAdminPurposeCode = (id: number, payload: Omit) => + request(`/admin/purpose-codes/${id}`, jsonInit('PUT', payload)); +export const getWatcher1Settings = () => request('/settings/watcher1'); +export const updateWatcher1Settings = (payload: Watcher1Settings) => + request('/admin/settings/watcher1', jsonInit('PUT', payload)); +export const listAdminUsers = () => request('/admin/users'); +export const updateAdminUserRoles = (id: number, roles: Role[]) => + request(`/admin/users/${id}/roles`, jsonInit('PUT', { roles })); +export const listAdminTeams = () => request('/admin/teams'); +export const createAdminTeam = (payload: Omit) => + request('/admin/teams', jsonInit('POST', payload)); +export const updateAdminTeam = (id: number, payload: Omit) => + request(`/admin/teams/${id}`, jsonInit('PUT', payload)); +export const updateAdminUserTeam = (id: number, teamId: number, applyDefaultRoles: boolean) => + request(`/admin/users/${id}/team`, jsonInit('PUT', { teamId, applyDefaultRoles })); +export const applyTeamDefaultRolesToUser = (id: number) => + request(`/admin/users/${id}/apply-team-default-roles`, { method: 'POST' }); +export const applyTeamDefaultRolesToMembers = (teamId: number) => + request(`/admin/teams/${teamId}/apply-default-roles`, { method: 'POST' }); +export const uploadAdminTeams = (file: File, dryRun: boolean) => { + const form = new FormData(); + form.append('file', file); + return request(`/admin/teams/upload?dryRun=${dryRun}`, { + method: 'POST', + body: form, + }); +}; +export const uploadAdminUserRoles = (file: File, dryRun: boolean) => { + const form = new FormData(); + form.append('file', file); + return request(`/admin/users/roles/upload?dryRun=${dryRun}`, { + method: 'POST', + body: form, + }); +}; +export const adminTeamTemplateUrl = () => '/api/admin/teams/template'; +export const adminUserRolesTemplateUrl = () => '/api/admin/users/roles/template'; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 68933a3..b850d95 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -29,11 +29,12 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => IT센터 출입자관리 diff --git a/frontend/src/components/VisitRequestDetailDialog.tsx b/frontend/src/components/VisitRequestDetailDialog.tsx new file mode 100644 index 0000000..52cf4f8 --- /dev/null +++ b/frontend/src/components/VisitRequestDetailDialog.tsx @@ -0,0 +1,162 @@ +import React, { useEffect, useState } from 'react'; +import { getVisitRequest, listTodayAccess } from '../api'; +import { AccessRecord, VisitRequestView } from '../types'; +import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status'; + +interface Props { + requestId: number; + onClose: () => void; +} + +export const VisitRequestDetailDialog: React.FC = ({ requestId, onClose }) => { + const [request, setRequest] = useState(null); + const [access, setAccess] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let alive = true; + setLoading(true); + setError(null); + Promise.all([ + getVisitRequest(requestId), + listTodayAccess().catch(() => [] as AccessRecord[]), + ]) + .then(([detail, records]) => { + if (!alive) return; + setRequest(detail); + setAccess(records.find((r) => r.visitRequestId === requestId) ?? null); + }) + .catch((e) => { + if (!alive) return; + setError(e instanceof Error ? e.message : '상세 조회에 실패했습니다.'); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [requestId]); + + const status = getDisplayStatus(request, access); + + return ( +
+
e.stopPropagation()}> +
+
+

출입 신청 상세

+ {request &&

{request.visitorName} / {request.zoneName || '-'}

} +
+ +
+ + {loading ? ( +

불러오는 중...

+ ) : error ? ( +
{error}
+ ) : request ? ( + <> +
+ {status.label} +
+
+
+ 방문자 +
+ + + + + +
+ 출입 전산실 +
+ {['4층전산실', '5층전산실'].map((room) => ( + + ))} +
+
+ + + + + + + +
+
+ +
+ 출입통제담당자 +
+ + + +
+
+ +
+ 현장감시자 +
현장감시자1 : IT센터 사무보조원
+
+ + + +
+ +
현장감시자2 : 작업을 입회할 상주직원
+
+ + + +
+
+ +
+ 출입 처리 현황 +
+ + + +
+
+
+ + ) : null} +
+
+ ); +}; + +const ReadOnlyField: React.FC<{ label: string; value?: string | null }> = ({ label, value }) => ( + +); + +function extraZone(zoneName?: string): string { + if (!zoneName) return '-'; + const parts = zoneName.split('/').map((part) => part.trim()).filter(Boolean); + if (parts.length > 1) return parts.slice(1).join(' / '); + if (zoneName.includes('전산실')) return '-'; + return zoneName; +} + +function getDisplayStatus(request: VisitRequestView | null, access: AccessRecord | null) { + if (access?.inside) { + return { label: '재실중', className: 'green' }; + } + if (access?.checkOutAt) { + return { label: '퇴장', className: 'gray' }; + } + if (!request) { + return { label: '-', className: 'gray' }; + } + return { label: STATUS_LABEL[request.status], className: STATUS_CLASS[request.status] }; +} diff --git a/frontend/src/pages/AccessConsolePage.tsx b/frontend/src/pages/AccessConsolePage.tsx index f73613c..e40fbf2 100644 --- a/frontend/src/pages/AccessConsolePage.tsx +++ b/frontend/src/pages/AccessConsolePage.tsx @@ -7,6 +7,7 @@ import { } from '../api'; import { AccessRecord, VisitRequestView } from '../types'; import { formatShort } from '../status'; +import { VisitRequestDetailDialog } from '../components/VisitRequestDetailDialog'; /** * Staff access console (login: 담당자/보안/관리자). Name search → force check-in/out, @@ -19,6 +20,7 @@ export const AccessConsolePage: React.FC = () => { const [records, setRecords] = useState([]); const [notice, setNotice] = useState(null); const [error, setError] = useState(null); + const [detailId, setDetailId] = useState(null); const [busy, setBusy] = useState(false); const loadRecords = () => { @@ -95,7 +97,18 @@ export const AccessConsolePage: React.FC = () => { {results.map((r) => ( - + setDetailId(r.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setDetailId(r.id); + } + }} + > {r.visitorName} {r.company || '-'} {r.zoneName || '-'} @@ -103,14 +116,28 @@ export const AccessConsolePage: React.FC = () => { {insideIds.has(r.id) ? ( 재실중 - + ) : exitedIds.has(r.id) ? ( 금일완료 ) : ( 입장대기 - + )} @@ -137,7 +164,18 @@ export const AccessConsolePage: React.FC = () => { {records.map((r) => ( - + setDetailId(r.visitRequestId)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setDetailId(r.visitRequestId); + } + }} + > {r.visitorName} {r.company || '-'} {r.zoneName || '-'} @@ -152,7 +190,14 @@ export const AccessConsolePage: React.FC = () => { {r.inside && ( - + )} @@ -161,6 +206,10 @@ export const AccessConsolePage: React.FC = () => { )} + + {detailId != null && ( + setDetailId(null)} /> + )} ); }; diff --git a/frontend/src/pages/AdminManagementPage.tsx b/frontend/src/pages/AdminManagementPage.tsx new file mode 100644 index 0000000..2ad4a00 --- /dev/null +++ b/frontend/src/pages/AdminManagementPage.tsx @@ -0,0 +1,584 @@ +import React, { useEffect, useState } from 'react'; +import { + adminTeamTemplateUrl, + adminUserRolesTemplateUrl, + applyTeamDefaultRolesToMembers, + createAdminTeam, + createAdminPurposeCode, + listAdminPurposeCodes, + listAdminTeams, + listAdminUsers, + updateAdminTeam, + updateAdminPurposeCode, + updateAdminUserTeam, + updateAdminUserRoles, + uploadAdminTeams, + uploadAdminUserRoles, + updateWatcher1Settings, + getWatcher1Settings, +} from '../api'; +import { AdminExcelImportResult, AdminUser, PurposeCode, Role, Team, Watcher1Settings } from '../types'; + +type Tab = 'watcher' | 'purpose' | 'teams' | 'roles'; + +const EMPTY_PURPOSE = { + code: '', + name: '', + sortOrder: 100, + active: true, + customAllowed: false, +}; + +const ALL_ROLES: Role[] = ['ADMIN', 'SECURITY', 'HOST']; +const EMPTY_TEAM: Omit = { + code: '', + name: '', + active: true, + defaultRoles: ['HOST'], +}; + +const statusLabel = (status: string) => { + if (status === 'CREATE') return '신규'; + if (status === 'UPDATE') return '수정'; + if (status === 'ERROR') return '오류'; + return status; +}; + +interface UploadPanelProps { + title: string; + templateUrl: string; + file: File | null; + result: AdminExcelImportResult | null; + busy: boolean; + onFileChange: (file: File | null) => void; + onPreview: () => void; + onApply: () => void; +} + +const ExcelUploadPanel: React.FC = ({ + title, + templateUrl, + file, + result, + busy, + onFileChange, + onPreview, + onApply, +}) => ( +
+
+

{title}

+ 양식 다운로드 +
+
+ onFileChange(e.target.files?.[0] ?? null)} + /> + + +
+ {result && ( +
+
+ 전체 {result.totalRows}건 + 신규 {result.createCount}건 + 수정 {result.updateCount}건 + 0 ? 'text-danger' : ''}>오류 {result.errorCount}건 + 경고 {result.warningCount}건 + {result.applied && 적용 완료} +
+ {result.rows.length > 0 && ( + + + + {result.rows.map((row) => ( + 0 ? 'row-error' : ''}> + + + + + + + ))} + +
상태내용검증 결과
{row.rowNumber}{statusLabel(row.status)}{row.key || '-'}{row.summary || '-'} + {row.errors.length === 0 && row.warnings.length === 0 && '정상'} + {row.errors.map((message) =>
{message}
)} + {row.warnings.map((message) =>
{message}
)} +
+ )} +
+ )} +
+); + +export const AdminManagementPage: React.FC = () => { + const [tab, setTab] = useState('watcher'); + const [watcher, setWatcher] = useState({ name: '', team: '', contact: '' }); + const [purposes, setPurposes] = useState([]); + const [purposeForm, setPurposeForm] = useState>(EMPTY_PURPOSE); + const [editingPurposeId, setEditingPurposeId] = useState(null); + const [teams, setTeams] = useState([]); + const [teamForm, setTeamForm] = useState>(EMPTY_TEAM); + const [editingTeamId, setEditingTeamId] = useState(null); + const [users, setUsers] = useState([]); + const [userQuery, setUserQuery] = useState(''); + const [teamFilter, setTeamFilter] = useState(''); + const [teamUploadFile, setTeamUploadFile] = useState(null); + const [teamUploadResult, setTeamUploadResult] = useState(null); + const [userRoleUploadFile, setUserRoleUploadFile] = useState(null); + const [userRoleUploadResult, setUserRoleUploadResult] = useState(null); + const [notice, setNotice] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const load = () => { + setError(null); + Promise.all([getWatcher1Settings(), listAdminPurposeCodes(), listAdminTeams(), listAdminUsers()]) + .then(([w, p, t, u]) => { + setWatcher(w); + setPurposes(p); + setTeams(t); + setUsers(u); + }) + .catch((e) => setError(e instanceof Error ? e.message : '관리 정보를 불러오지 못했습니다.')); + }; + + useEffect(load, []); + + const saveWatcher = async (e: React.FormEvent) => { + e.preventDefault(); + setBusy(true); + setError(null); + try { + setWatcher(await updateWatcher1Settings(watcher)); + setNotice('현장감시자1 정보가 저장되었습니다.'); + } catch (err) { + setError(err instanceof Error ? err.message : '저장 실패'); + } finally { + setBusy(false); + } + }; + + const editPurpose = (p: PurposeCode) => { + setEditingPurposeId(p.id); + setPurposeForm({ + code: p.code, + name: p.name, + sortOrder: p.sortOrder, + active: p.active, + customAllowed: p.customAllowed, + }); + }; + + const resetPurposeForm = () => { + setEditingPurposeId(null); + setPurposeForm(EMPTY_PURPOSE); + }; + + const editTeam = (team: Team) => { + setEditingTeamId(team.id); + setTeamForm({ + code: team.code, + name: team.name, + active: team.active, + defaultRoles: team.defaultRoles, + }); + }; + + const resetTeamForm = () => { + setEditingTeamId(null); + setTeamForm(EMPTY_TEAM); + }; + + const toggleTeamDefaultRole = (role: Role) => { + const next = teamForm.defaultRoles.includes(role) + ? teamForm.defaultRoles.filter((r) => r !== role) + : [...teamForm.defaultRoles, role]; + setTeamForm({ ...teamForm, defaultRoles: next }); + }; + + const saveTeam = async (e: React.FormEvent) => { + e.preventDefault(); + if (teamForm.defaultRoles.length === 0) { + setError('팀 기본권한은 최소 1개 이상 필요합니다.'); + return; + } + setBusy(true); + setError(null); + try { + const saved = editingTeamId == null + ? await createAdminTeam(teamForm) + : await updateAdminTeam(editingTeamId, teamForm); + setTeams((prev) => { + const others = prev.filter((t) => t.id !== saved.id); + return [...others, saved].sort((a, b) => a.name.localeCompare(b.name)); + }); + resetTeamForm(); + setNotice('팀 정보가 저장되었습니다.'); + } catch (err) { + setError(err instanceof Error ? err.message : '저장 실패'); + } finally { + setBusy(false); + } + }; + + const savePurpose = async (e: React.FormEvent) => { + e.preventDefault(); + setBusy(true); + setError(null); + try { + const saved = editingPurposeId == null + ? await createAdminPurposeCode(purposeForm) + : await updateAdminPurposeCode(editingPurposeId, purposeForm); + setPurposes((prev) => { + const others = prev.filter((p) => p.id !== saved.id); + return [...others, saved].sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)); + }); + resetPurposeForm(); + setNotice('출입목적 코드가 저장되었습니다.'); + } catch (err) { + setError(err instanceof Error ? err.message : '저장 실패'); + } finally { + setBusy(false); + } + }; + + const toggleRole = (user: AdminUser, role: Role) => { + const next = user.roles.includes(role) + ? user.roles.filter((r) => r !== role) + : [...user.roles, role]; + if (next.length === 0) { + setError('권한은 최소 1개 이상 필요합니다.'); + return; + } + setError(null); + setUsers((prev) => prev.map((u) => (u.id === user.id ? { ...u, roles: next } : u))); + }; + + const changeUserTeam = (user: AdminUser, teamId: number) => { + const team = teams.find((t) => t.id === teamId); + setError(null); + setUsers((prev) => prev.map((u) => (u.id === user.id + ? { ...u, teamId, teamCode: team?.code, teamName: team?.name, department: team?.name ?? u.department } + : u))); + }; + + const saveUser = async (user: AdminUser) => { + if (user.roles.length === 0) { + setError('권한은 최소 1개 이상 필요합니다.'); + return; + } + setBusy(true); + setError(null); + try { + let updated = user; + if (user.teamId) { + updated = await updateAdminUserTeam(user.id, user.teamId, false); + } + updated = await updateAdminUserRoles(user.id, user.roles); + setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u))); + setNotice(`${user.username} 정보가 저장되었습니다.`); + } catch (err) { + setError(err instanceof Error ? err.message : '사용자 권한 저장 실패'); + } finally { + setBusy(false); + } + }; + + const applyTeamDefaults = async (team: Team) => { + setBusy(true); + setError(null); + try { + const message = await applyTeamDefaultRolesToMembers(team.id); + setUsers(await listAdminUsers()); + setNotice(message); + } catch (err) { + setError(err instanceof Error ? err.message : '기본권한 적용 실패'); + } finally { + setBusy(false); + } + }; + + const previewTeamUpload = async () => { + if (!teamUploadFile) { + setError('업로드할 엑셀 파일을 선택하세요.'); + return; + } + setBusy(true); + setError(null); + try { + setTeamUploadResult(await uploadAdminTeams(teamUploadFile, true)); + setNotice('팀 엑셀 검증이 완료되었습니다.'); + } catch (err) { + setError(err instanceof Error ? err.message : '팀 엑셀 검증 실패'); + } finally { + setBusy(false); + } + }; + + const applyTeamUpload = async () => { + if (!teamUploadFile || !teamUploadResult || !teamUploadResult.success) { + setError('오류가 없는 미리보기 결과가 있어야 적용할 수 있습니다.'); + return; + } + setBusy(true); + setError(null); + try { + const result = await uploadAdminTeams(teamUploadFile, false); + setTeamUploadResult(result); + setTeams(await listAdminTeams()); + setUsers(await listAdminUsers()); + setNotice(`팀 엑셀 적용 완료: 신규 ${result.createCount}건, 수정 ${result.updateCount}건`); + } catch (err) { + setError(err instanceof Error ? err.message : '팀 엑셀 적용 실패'); + } finally { + setBusy(false); + } + }; + + const previewUserRoleUpload = async () => { + if (!userRoleUploadFile) { + setError('업로드할 엑셀 파일을 선택하세요.'); + return; + } + setBusy(true); + setError(null); + try { + setUserRoleUploadResult(await uploadAdminUserRoles(userRoleUploadFile, true)); + setNotice('권한 엑셀 검증이 완료되었습니다.'); + } catch (err) { + setError(err instanceof Error ? err.message : '권한 엑셀 검증 실패'); + } finally { + setBusy(false); + } + }; + + const applyUserRoleUpload = async () => { + if (!userRoleUploadFile || !userRoleUploadResult || !userRoleUploadResult.success) { + setError('오류가 없는 미리보기 결과가 있어야 적용할 수 있습니다.'); + return; + } + setBusy(true); + setError(null); + try { + const result = await uploadAdminUserRoles(userRoleUploadFile, false); + setUserRoleUploadResult(result); + setTeams(await listAdminTeams()); + setUsers(await listAdminUsers()); + setNotice(`권한 엑셀 적용 완료: 수정 ${result.updateCount}건`); + } catch (err) { + setError(err instanceof Error ? err.message : '권한 엑셀 적용 실패'); + } finally { + setBusy(false); + } + }; + + const filteredUsers = users.filter((u) => { + const q = userQuery.trim().toLowerCase(); + const matchesQuery = !q + || u.username.toLowerCase().includes(q) + || u.fullName.toLowerCase().includes(q) + || (u.teamName ?? u.department ?? '').toLowerCase().includes(q); + const matchesTeam = !teamFilter || String(u.teamId ?? '') === teamFilter; + return matchesQuery && matchesTeam; + }); + + return ( +
+

시스템 관리

+ {notice &&
{notice}
} + {error &&
{error}
} + +
+ + + + +
+ + {tab === 'watcher' && ( +
+ + + +
+ +
+
+ )} + + {tab === 'purpose' && ( + <> +
+ + + + + +
+ + +
+
+ +
+ + + + {purposes.map((p) => ( + + + + + + + + + ))} + +
코드표시명순서사용기타
{p.code}{p.name}{p.sortOrder}{p.active ? 'Y' : 'N'}{p.customAllowed ? 'Y' : 'N'}
+
+ + )} + + {tab === 'teams' && ( + <> + { + setTeamUploadFile(file); + setTeamUploadResult(null); + }} + onPreview={() => void previewTeamUpload()} + onApply={() => void applyTeamUpload()} + /> + +
+ + + +
+ 기본권한 +
+ {ALL_ROLES.map((role) => ( + + ))} +
+
+
+ + +
+
+ +
+ + + + {teams.map((team) => ( + + + + + + + + + ))} + +
팀 코드팀명기본권한사용
{team.code}{team.name}{team.defaultRoles.map((r) => (r === 'HOST' ? 'USER' : r)).join(', ') || '-'}{team.active ? 'Y' : 'N'}
+
+ + )} + + {tab === 'roles' && ( + <> + { + setUserRoleUploadFile(file); + setUserRoleUploadResult(null); + }} + onPreview={() => void previewUserRoleUpload()} + onApply={() => void applyUserRoleUpload()} + /> + +
+
+ setUserQuery(e.target.value)} placeholder="아이디, 이름, 팀 검색" /> + +
+ + + + {filteredUsers.map((u) => ( + + + + + + + + ))} + +
아이디이름권한
{u.username}{u.fullName} + + +
+ {ALL_ROLES.map((role) => ( + + ))} +
+
+ +
+
+ + )} +
+ ); +}; diff --git a/frontend/src/pages/AuditLogPage.tsx b/frontend/src/pages/AuditLogPage.tsx index c2122a0..8d44f1a 100644 --- a/frontend/src/pages/AuditLogPage.tsx +++ b/frontend/src/pages/AuditLogPage.tsx @@ -8,6 +8,7 @@ const ACTION_LABEL: Record = { REJECT: '반려', BLACKLIST_ADD: '블랙리스트 등록', BLACKLIST_REMOVE: '블랙리스트 해제', + ADMIN_CONFIG_UPDATE: '시스템 설정', }; const ACTION_CLASS: Record = { @@ -15,6 +16,7 @@ const ACTION_CLASS: Record = { REJECT: 'red', BLACKLIST_ADD: 'red', BLACKLIST_REMOVE: 'gray', + ADMIN_CONFIG_UPDATE: 'blue', }; export const AuditLogPage: React.FC = () => { diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index aa56754..79a14f6 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,13 +1,17 @@ import React, { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { getStatsSummary, listInside, listVisitRequests } from '../api'; +import { getStatsSummary, listInside, listTodayAccess, listVisitRequests } from '../api'; import { StatsSummary, VisitRequestView } from '../types'; import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status'; +import { VisitRequestDetailDialog } from '../components/VisitRequestDetailDialog'; export const DashboardPage: React.FC = () => { const [items, setItems] = useState([]); const [stats, setStats] = useState(null); const [insideIds, setInsideIds] = useState>(new Set()); + const [exitedIds, setExitedIds] = useState>(new Set()); + const [checkOutById, setCheckOutById] = useState>(new Map()); + const [detailId, setDetailId] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -17,6 +21,19 @@ export const DashboardPage: React.FC = () => { listInside() .then((rows) => setInsideIds(new Set(rows.map((r) => r.visitRequestId)))) .catch(() => setInsideIds(new Set())); + listTodayAccess() + .then((rows) => { + setExitedIds(new Set(rows + .filter((r) => !r.inside && r.checkOutAt) + .map((r) => r.visitRequestId))); + setCheckOutById(new Map(rows + .filter((r) => r.checkOutAt) + .map((r) => [r.visitRequestId, r.checkOutAt as string]))); + }) + .catch(() => { + setExitedIds(new Set()); + setCheckOutById(new Map()); + }); listVisitRequests() .then(setItems) .catch((e) => setError(e instanceof Error ? e.message : '조회 실패')) @@ -51,19 +68,33 @@ export const DashboardPage: React.FC = () => { - + {recent.map((r) => ( - + setDetailId(r.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setDetailId(r.id); + } + }} + > +
방문자회사출입구역출입 일시상태방문자회사출입구역출입 일시퇴장 일시상태
{r.visitorName} {r.company || '-'} {r.zoneName || '-'} {formatDateTime(r.visitFrom)}{checkOutById.has(r.id) ? formatDateTime(checkOutById.get(r.id)!) : '-'} {insideIds.has(r.id) ? ( 재실중 + ) : exitedIds.has(r.id) ? ( + 퇴장 ) : ( {STATUS_LABEL[r.status]} )} @@ -74,6 +105,10 @@ export const DashboardPage: React.FC = () => {
)} + + {detailId != null && ( + setDetailId(null)} /> + )} ); }; diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index bc9619b..0ee2399 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -57,6 +57,7 @@ export const LoginPage: React.FC = () => { + ); diff --git a/frontend/src/pages/ReportPage.tsx b/frontend/src/pages/ReportPage.tsx index 246f4fb..9c75814 100644 --- a/frontend/src/pages/ReportPage.tsx +++ b/frontend/src/pages/ReportPage.tsx @@ -1,6 +1,8 @@ -import React, { useState } from 'react'; -import { reportDownloadUrl } from '../api'; +import React, { useEffect, useState } from 'react'; +import { listReportVisits, reportDownloadUrl } from '../api'; import { DatePickerField } from '../components/DatePickerField'; +import { VisitRequestView } from '../types'; +import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status'; const pad = (n: number) => String(n).padStart(2, '0'); @@ -18,6 +20,27 @@ function firstOfMonthISO(): string { export const ReportPage: React.FC = () => { const [from, setFrom] = useState(firstOfMonthISO()); const [to, setTo] = useState(todayISO()); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const load = () => { + setLoading(true); + setError(null); + listReportVisits(from, to) + .then(setItems) + .catch((e) => { + setItems([]); + setError(e instanceof Error ? e.message : '보고서 조회에 실패했습니다.'); + }) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + load(); + // Load the default month range once; explicit 조회 handles later date changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const onDownload = () => { // Trigger a download in-place via a temporary anchor. Using window.open left @@ -37,7 +60,6 @@ export const ReportPage: React.FC = () => {

출입관리 보고서

-

기간을 선택하고 [엑셀 다운로드] 버튼을 클릭하면, 엑셀(.xlsx) 파일로 내려받습니다. (방문 시작일 기준)

+
+ + {error &&
{error}
} + +
+
+

조회 결과 ({items.length})

+
+ + {loading ? ( +

불러오는 중...

+ ) : items.length === 0 ? ( +

조회된 출입 신청이 없습니다.

+ ) : ( + + + + + + + + + + + + + + + + + {items.map((r) => ( + + + + + + + + + + + + + ))} + +
방문자회사연락처출입구역호스트출입목적작업명출입일시퇴실일시상태
{r.visitorName}{r.company || '-'}{r.contact || '-'}{r.zoneName || '-'}{r.hostName}{r.purpose || '-'}{r.workName || '-'}{formatDateTime(r.visitFrom)}{formatDateTime(r.visitTo)}{STATUS_LABEL[r.status]}
+ )} +
); }; diff --git a/frontend/src/pages/VisitRequestFormPage.tsx b/frontend/src/pages/VisitRequestFormPage.tsx index b337d39..218315f 100644 --- a/frontend/src/pages/VisitRequestFormPage.tsx +++ b/frontend/src/pages/VisitRequestFormPage.tsx @@ -1,15 +1,23 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { createVisitRequest } from '../api'; +import { createVisitRequest, getWatcher1Settings, listPurposeCodes } from '../api'; import { DateTimePicker } from '../components/DateTimePicker'; import { useAuth } from '../auth/AuthContext'; +import { PurposeCode, Watcher1Settings } from '../types'; // 코드 시트 목록을 콤보/체크박스에 반영. // 전산실: 체크박스(다중). 선택한 개수만큼 신청/QR이 생성된다. const SERVER_ROOM_OPTIONS = ['4층전산실', '5층전산실']; // 추가 구역: 콤보박스(코드 시트 장소 중 전산실 외). 부가정보로만 기록. '기타' 선택 시 자유 입력. const ROOM_OPTIONS = ['4층종합상황실', '4층BMT실', '3층사무실', '기타']; -const PURPOSE_OPTIONS = ['점검', '작업', '견학', '회의', '청소', '기타']; +const FALLBACK_PURPOSE_CODES: PurposeCode[] = [ + { id: 1, code: 'INSPECTION', name: '점검', sortOrder: 10, active: true, customAllowed: false }, + { id: 2, code: 'WORK', name: '작업', sortOrder: 20, active: true, customAllowed: false }, + { id: 3, code: 'TOUR', name: '견학', sortOrder: 30, active: true, customAllowed: false }, + { id: 4, code: 'MEETING', name: '회의', sortOrder: 40, active: true, customAllowed: false }, + { id: 5, code: 'CLEANING', name: '청소', sortOrder: 50, active: true, customAllowed: false }, + { id: 6, code: 'ETC', name: '기타', sortOrder: 900, active: true, customAllowed: true }, +]; // 소속(코드 시트) — 내부 팀. 담당자·감시자 팀 콤보에 사용. const AFFILIATION_OPTIONS = [ 'IT센터관리팀', 'IT서비스팀', '네트워크팀', '클라우드팀', 'RTGS시스템팀', @@ -17,18 +25,24 @@ const AFFILIATION_OPTIONS = [ 'IT리스크팀', 'IT기획팀', '정보기획팀', 'IT전략국', ]; // 현장감시자1 — 고정 인원(백엔드 FIXED_WATCHER1과 동일 값 유지). -const FIXED_WATCHER1 = { name: '류관순', team: 'IT전략국', contact: '313' }; +const FALLBACK_WATCHER1: Watcher1Settings = { name: '류관순', team: 'IT전략국', contact: '313' }; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -/** True if the datetime's calendar date is before today (time-of-day ignored). */ -const isPastDate = (iso: string): boolean => { - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return false; - const day = new Date(d.getFullYear(), d.getMonth(), d.getDate()); - const today = new Date(); - const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate()); - return day < todayStart; +const NEXT_DAY_EXIT_MESSAGE = `퇴장일이 출입일 다음날 이후가 되는 경우는 2건으로 신청해야 합니다 +예컨대, 2026.07.16 18:00~2026.07.17 04:00 이라면, 아래와 같이 2건으로 등록해야 합니다. +1건) 2026.07.16 18:00~2026.07.16 24:00 +2건) 2026.07.17 00:00~2026.07.17 04:00`; + +const EXIT_BEFORE_ENTRY_MESSAGE = '퇴장일시는 출입일시 이후로 입력해야 합니다.'; + +const shouldShowValidationAlert = (message: string): boolean => + message === NEXT_DAY_EXIT_MESSAGE || message === EXIT_BEFORE_ENTRY_MESSAGE; + +const isLaterCalendarDate = (later: Date, earlier: Date): boolean => { + const laterDay = new Date(later.getFullYear(), later.getMonth(), later.getDate()); + const earlierDay = new Date(earlier.getFullYear(), earlier.getMonth(), earlier.getDate()); + return laterDay > earlierDay; }; const formatPhoneLike = (value: string): string => { @@ -58,12 +72,21 @@ export const VisitRequestFormPage: React.FC = () => { visitFrom: '', visitTo: '', }); + const [purposeCodes, setPurposeCodes] = useState(FALLBACK_PURPOSE_CODES); + const [watcher1, setWatcher1] = useState(FALLBACK_WATCHER1); const [consent, setConsent] = useState(false); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const navigate = useNavigate(); const { user } = useAuth(); + useEffect(() => { + listPurposeCodes().then(setPurposeCodes).catch(() => setPurposeCodes(FALLBACK_PURPOSE_CODES)); + getWatcher1Settings().then(setWatcher1).catch(() => setWatcher1(FALLBACK_WATCHER1)); + }, []); + + const selectedPurpose = purposeCodes.find((p) => p.code === form.purpose); + const update = (k: keyof typeof form) => ( e: React.ChangeEvent, ) => setForm({ ...form, [k]: e.target.value }); @@ -90,13 +113,13 @@ export const VisitRequestFormPage: React.FC = () => { return '출입 구역(전산실 또는 추가 구역)을 최소 1개 이상 선택하세요.'; if (form.room === '기타' && !form.roomEtc.trim()) return '기타 추가 구역을 입력하세요.'; if (!form.purpose) return '출입 목적을 선택하세요.'; - if (form.purpose === '기타' && !form.purposeEtc.trim()) return '기타 출입 목적을 입력하세요.'; + if (selectedPurpose?.customAllowed && !form.purposeEtc.trim()) return '기타 출입 목적을 입력하세요.'; if (!form.visitFrom) return '출입 일시를 입력하세요.'; - // 오늘 이전(전일자)은 불가. 같은 날 안에서 현재보다 이른 시각은 허용(날짜만 비교). - if (isPastDate(form.visitFrom)) return '과거일자는 입력이 안됩니다.'; if (!form.visitTo) return '퇴실 일시를 입력하세요.'; - if (new Date(form.visitTo) < new Date(form.visitFrom)) - return '퇴실 일시는 출입 일시보다 빠를 수 없습니다.'; + const visitFrom = new Date(form.visitFrom); + const visitTo = new Date(form.visitTo); + if (visitFrom > visitTo) return EXIT_BEFORE_ENTRY_MESSAGE; + if (isLaterCalendarDate(visitTo, visitFrom)) return NEXT_DAY_EXIT_MESSAGE; if (!consent) return '개인정보 사용 및 저장에 동의해야 신청할 수 있습니다.'; return null; }; @@ -106,6 +129,9 @@ export const VisitRequestFormPage: React.FC = () => { const message = validate(); if (message) { setError(message); + if (shouldShowValidationAlert(message)) { + window.alert(message); + } return; } setError(null); @@ -120,7 +146,11 @@ export const VisitRequestFormPage: React.FC = () => { vehicleNo: form.vehicleNo.trim() || undefined, serverRooms: form.serverRooms, roomZone: roomZone || undefined, - purpose: form.purpose === '기타' ? form.purposeEtc.trim() : form.purpose, + purpose: selectedPurpose?.customAllowed + ? form.purposeEtc.trim() + : selectedPurpose?.name ?? form.purpose, + purposeCode: selectedPurpose?.code ?? form.purpose, + purposeDetail: selectedPurpose?.customAllowed ? form.purposeEtc.trim() : undefined, workName: form.workName.trim() || undefined, watcher2Name: form.watcher2Name.trim() || undefined, watcher2Team: form.watcher2Team.trim() || undefined, @@ -201,10 +231,10 @@ export const VisitRequestFormPage: React.FC = () => { 출입 목적 * - {form.purpose === '기타' ? ( + {selectedPurpose?.customAllowed ? ( @@ -295,10 +325,12 @@ export const VisitRequestFormPage: React.FC = () => { diff --git a/frontend/src/styles/common.css b/frontend/src/styles/common.css index 7353a74..cc52529 100644 --- a/frontend/src/styles/common.css +++ b/frontend/src/styles/common.css @@ -140,6 +140,48 @@ a { color: inherit; text-decoration: none; } .modal-message { margin: 0 0 12px; color: var(--muted); font-size: 14px; } .modal-input { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; resize: vertical; } .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } +.detail-modal { + width: min(1120px, calc(100vw - 48px)); + max-height: calc(100vh - 48px); + overflow: auto; +} +.detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 12px; } +.detail-status { margin-bottom: 12px; } +.detail-form { + width: 100%; + min-width: 0; +} +.detail-form .form-group { + min-width: 0; +} +.detail-form .group-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.detail-form .field { + grid-template-columns: 104px minmax(0, 1fr); +} +.detail-form .field input { + width: 100%; + min-width: 0; +} +.detail-form .checkbox-row { + min-width: 0; +} +.detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 16px; +} +.detail-item { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + gap: 8px; + align-items: start; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} +.detail-item span { color: var(--muted); font-size: 12px; font-weight: 700; } +.detail-item strong { font-size: 14px; font-weight: 600; overflow-wrap: anywhere; } /* Public entrance kiosk */ .kiosk { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; background: var(--bg, #f1f5f9); } @@ -175,7 +217,7 @@ a { color: inherit; text-decoration: none; } .form-grid .span-2 { grid-column: 1 / -1; } .visit-form { gap: 8px; padding: 12px; margin-bottom: 0; } .form-group { - border: 1px solid #cbd5e1; + border: 2px solid #94a3b8; border-radius: 8px; padding: 10px 12px 0; margin: 0; @@ -244,6 +286,7 @@ a { color: inherit; text-decoration: none; } } .consent-label { display: flex; gap: 10px; align-items: flex-start; cursor: pointer; font-size: 12px; color: #334155; line-height: 1.35; } .consent-label input { margin-top: 2px; width: 16px; height: 16px; flex-shrink: 0; } +.consent-retention-warning { color: var(--red); } /* Inline checkbox group (e.g. 전산실 다중 선택) */ .checkbox-row { display: flex; flex-wrap: wrap; gap: 10px; padding: 4px 2px; } @@ -290,6 +333,8 @@ button:disabled { opacity: .55; cursor: not-allowed; } } .table th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: none; } .table tbody tr:hover { background: #f8fafc; } +.clickable-row { cursor: pointer; } +.clickable-row:focus-visible { outline: 2px solid #bfdbfe; outline-offset: -2px; } /* ===== Badges ===== */ .badge { @@ -325,6 +370,81 @@ button:disabled { opacity: .55; cursor: not-allowed; } .btn-link { background: none; border: none; color: var(--primary); font-weight: 600; } .row-actions { display: flex; gap: 10px; } +/* ===== Admin management ===== */ +.admin-tabs { + display: flex; + gap: 8px; + margin-bottom: 12px; + border-bottom: 1px solid var(--border); +} +.admin-tabs button { + border: 0; + background: transparent; + padding: 10px 12px; + color: var(--muted); + font-weight: 700; +} +.admin-tabs button.tab-active { + color: var(--primary); + border-bottom: 2px solid var(--primary); +} +.admin-filter-row { + display: flex; + gap: 10px; + margin-bottom: 12px; +} +.admin-filter-row input, +.admin-filter-row select, +.table select { + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + font: inherit; + background: #fff; +} +.admin-filter-row input { + min-width: 260px; +} +.admin-upload-panel { + margin-bottom: 12px; +} +.admin-upload-head, +.admin-upload-controls, +.admin-upload-summary { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.admin-upload-head { + justify-content: space-between; + margin-bottom: 10px; +} +.admin-upload-head h3 { + margin: 0; + font-size: 16px; +} +.admin-upload-controls input[type="file"] { + min-width: 280px; +} +.admin-upload-summary { + margin: 12px 0; + color: var(--muted); + font-size: 13px; +} +.admin-preview-table { + margin-top: 8px; +} +.admin-preview-table td { + vertical-align: top; +} +.row-error { + background: #fff7f7; +} +.text-danger { + color: var(--red); +} + /* ===== Report ===== */ .report-row { display: flex; align-items: flex-end; gap: 16px; } .report-row .field { margin-bottom: 0; } @@ -362,6 +482,10 @@ button:disabled { opacity: .55; cursor: not-allowed; } .stat-grid { grid-template-columns: repeat(2, 1fr); } .form-grid { grid-template-columns: 1fr; } .group-grid { grid-template-columns: 1fr; } + .detail-grid, + .detail-item { + grid-template-columns: 1fr; + } .visit-form .field, .visit-form .field.span-2 { grid-template-columns: 1fr; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 98091ce..7d118c0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -33,6 +33,43 @@ export interface Zone { securityLevel: number; } +export interface PurposeCode { + id: number; + code: string; + name: string; + sortOrder: number; + active: boolean; + customAllowed: boolean; +} + +export interface Watcher1Settings { + name: string; + team: string; + contact: string; +} + +export interface Team { + id: number; + code: string; + name: string; + active: boolean; + defaultRoles: Role[]; +} + +export interface AdminUser { + id: number; + username: string; + fullName: string; + department?: string; + teamId?: number; + teamCode?: string; + teamName?: string; + email?: string; + enabled: boolean; + locked: boolean; + roles: Role[]; +} + export type VisitStatus = | 'DRAFT' | 'PENDING' @@ -52,6 +89,8 @@ export interface VisitRequestCreate { /** Detail room (콤보박스, 기타 자유 입력) — auxiliary, no separate QR. */ roomZone?: string; purpose: string; + purposeCode?: string; + purposeDetail?: string; /** 작업명 — optional concrete task detail, stored separately from purpose. */ workName?: string; /** 현장감시자2 (담당자 입력). 담당자·감시자1은 서버가 채운다. */ @@ -67,12 +106,15 @@ export interface VisitRequestView { visitorName: string; company?: string; contact?: string; + email?: string; vehicleNo?: string; hostId: number; hostName: string; hostDepartment?: string; zoneName?: string; purpose: string; + purposeCode?: string; + purposeDetail?: string; workName?: string; controlName?: string; controlTeam?: string; @@ -109,6 +151,26 @@ export interface ExcelImportResult { success: boolean; } +export interface AdminExcelImportRow { + rowNumber: number; + status: 'CREATE' | 'UPDATE' | 'ERROR' | string; + key?: string; + summary?: string; + errors: string[]; + warnings: string[]; +} + +export interface AdminExcelImportResult { + totalRows: number; + createCount: number; + updateCount: number; + errorCount: number; + warningCount: number; + applied: boolean; + success: boolean; + rows: AdminExcelImportRow[]; +} + export interface InsideVisitor { visitRequestId: number; visitorName: string; @@ -170,7 +232,7 @@ export interface AuditLog { at: string; actorId?: number; actorUsername?: string; - action: 'APPROVE' | 'REJECT' | 'BLACKLIST_ADD' | 'BLACKLIST_REMOVE'; + action: 'APPROVE' | 'REJECT' | 'BLACKLIST_ADD' | 'BLACKLIST_REMOVE' | 'ADMIN_CONFIG_UPDATE'; targetType?: string; targetId?: number; detail?: string; diff --git a/migrations/006_admin_config.sql b/migrations/006_admin_config.sql new file mode 100644 index 0000000..738b5b5 --- /dev/null +++ b/migrations/006_admin_config.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS purpose_codes ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + code VARCHAR(40) NOT NULL UNIQUE, + name VARCHAR(80) NOT NULL, + sort_order INTEGER NOT NULL, + active BOOLEAN NOT NULL, + custom_allowed BOOLEAN NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_purpose_code_active_sort ON purpose_codes (active, sort_order); + +CREATE TABLE IF NOT EXISTS system_settings ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + setting_key VARCHAR(80) NOT NULL UNIQUE, + setting_value VARCHAR(255) +); + +ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS work_name VARCHAR(255); +ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS control_name VARCHAR(80); +ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS control_team VARCHAR(80); +ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS control_contact VARCHAR(60); +ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS watcher1_name VARCHAR(80); +ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS watcher1_team VARCHAR(80); +ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS watcher1_contact VARCHAR(60); +ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS watcher2_name VARCHAR(80); +ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS watcher2_team VARCHAR(80); +ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS watcher2_contact VARCHAR(60); +ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS purpose_code VARCHAR(40); +ALTER TABLE visit_requests ADD COLUMN IF NOT EXISTS purpose_detail VARCHAR(255); + +INSERT INTO purpose_codes (created_at, updated_at, code, name, sort_order, active, custom_allowed) +VALUES + (now(), now(), 'WORK', '작업', 10, TRUE, FALSE), + (now(), now(), 'INSPECTION', '점검', 20, TRUE, FALSE), + (now(), now(), 'DELIVERY', '납품', 30, TRUE, FALSE), + (now(), now(), 'MEETING', '회의', 40, TRUE, FALSE), + (now(), now(), 'VISIT', '방문', 50, TRUE, FALSE), + (now(), now(), 'ETC', '기타', 100, TRUE, TRUE) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO system_settings (created_at, updated_at, setting_key, setting_value) +VALUES + (now(), now(), 'watcher1.name', '류관순'), + (now(), now(), 'watcher1.team', 'IT전략국'), + (now(), now(), 'watcher1.contact', '313') +ON CONFLICT (setting_key) DO NOTHING; diff --git a/migrations/007_teams.sql b/migrations/007_teams.sql new file mode 100644 index 0000000..7d0cd7a --- /dev/null +++ b/migrations/007_teams.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS teams ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + code VARCHAR(40) NOT NULL UNIQUE, + name VARCHAR(80) NOT NULL UNIQUE, + active BOOLEAN NOT NULL +); + +CREATE TABLE IF NOT EXISTS team_default_roles ( + team_id BIGINT NOT NULL REFERENCES teams (id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL, + PRIMARY KEY (team_id, role) +); + +ALTER TABLE users ADD COLUMN IF NOT EXISTS team_id BIGINT REFERENCES teams (id); + +INSERT INTO teams (created_at, updated_at, code, name, active) +VALUES + (now(), now(), 'ITOPS', 'IT운영팀', TRUE), + (now(), now(), 'DEV1', '개발1팀', TRUE), + (now(), now(), 'SEC', '보안팀', TRUE) +ON CONFLICT (code) DO UPDATE +SET name = EXCLUDED.name, + active = EXCLUDED.active, + updated_at = now(); + +INSERT INTO team_default_roles (team_id, role) +SELECT id, 'ADMIN' FROM teams WHERE code = 'ITOPS' +ON CONFLICT DO NOTHING; +INSERT INTO team_default_roles (team_id, role) +SELECT id, 'HOST' FROM teams WHERE code = 'DEV1' +ON CONFLICT DO NOTHING; +INSERT INTO team_default_roles (team_id, role) +SELECT id, 'SECURITY' FROM teams WHERE code = 'SEC' +ON CONFLICT DO NOTHING; + +UPDATE users SET team_id = (SELECT id FROM teams WHERE code = 'ITOPS'), department = 'IT운영팀' +WHERE username IN ('admin', 'a') AND team_id IS NULL; +UPDATE users SET team_id = (SELECT id FROM teams WHERE code = 'DEV1'), department = '개발1팀' +WHERE username IN ('host', 'h') AND team_id IS NULL; +UPDATE users SET team_id = (SELECT id FROM teams WHERE code = 'SEC'), department = '보안팀' +WHERE username IN ('security', 's') AND team_id IS NULL; diff --git a/package.json b/package.json index b894b2d..f631410 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "build:frontend": "npm --prefix frontend run build", "build:server": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "npm run test:node", + "test:node": "npm run build:server && node --test --test-isolation=none dist/server/**/*.test.js", "db:check": "node scripts/check-db.mjs", "minio:check": "node scripts/check-minio.mjs", "dev": "tsx watch server/index.ts", diff --git a/server/routes/admin.ts b/server/routes/admin.ts new file mode 100644 index 0000000..68bcae4 --- /dev/null +++ b/server/routes/admin.ts @@ -0,0 +1,682 @@ +import type { NextFunction, Request, Response } from 'express'; +import { Router } from 'express'; +import multer from 'multer'; +import ExcelJS from 'exceljs'; +import { ok } from '../http/apiResponse.js'; +import { ApiError } from '../http/errors.js'; +import { query } from '../db/pool.js'; +import { parseRole, parseRoles, requireAdmin, requireCurrentUser, type QueryFn, type UserRow } from './security.js'; + +type Row = Record; + +interface TeamRow extends Row { + id: string; + code: string; + name: string; + active: boolean; + default_roles: string[]; +} + +interface PurposeRow extends Row { + id: string; + code: string; + name: string; + sort_order: number; + active: boolean; + custom_allowed: boolean; +} + +interface AdminUserRow extends Row { + id: string; + username: string; + full_name: string; + department: string | null; + team_id: string | null; + team_code: string | null; + team_name: string | null; + email: string | null; + enabled: boolean; + locked: boolean; + roles: string[]; +} + +interface ImportRow { + rowNumber: number; + status: 'CREATE' | 'UPDATE' | 'ERROR'; + key?: string; + summary?: string; + errors: string[]; + warnings: string[]; +} + +interface ImportResult { + totalRows: number; + createCount: number; + updateCount: number; + errorCount: number; + warningCount: number; + applied: boolean; + success: boolean; + rows: ImportRow[]; +} + +interface AdminRouterDeps { + query: QueryFn; +} + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 2 * 1024 * 1024 }, +}); + +function asyncRoute(handler: (req: Request, res: Response, next: NextFunction) => Promise) { + return (req: Request, res: Response, next: NextFunction) => { + handler(req, res, next).catch(next); + }; +} + +function toTeam(row: TeamRow) { + return { + id: Number(row.id), + code: row.code, + name: row.name, + active: row.active, + defaultRoles: row.default_roles ?? [], + }; +} + +function toPurpose(row: PurposeRow) { + return { + id: Number(row.id), + code: row.code, + name: row.name, + sortOrder: row.sort_order, + active: row.active, + customAllowed: row.custom_allowed, + }; +} + +function toAdminUser(row: AdminUserRow) { + return { + id: Number(row.id), + username: row.username, + fullName: row.full_name, + department: row.department ?? undefined, + teamId: row.team_id ? Number(row.team_id) : undefined, + teamCode: row.team_code ?? undefined, + teamName: row.team_name ?? undefined, + email: row.email ?? undefined, + enabled: row.enabled, + locked: row.locked, + roles: row.roles ?? [], + }; +} + +async function audit(dbQuery: QueryFn, actor: UserRow, targetType: string, targetId: number | null, detail: string) { + await dbQuery( + ` + INSERT INTO audit_logs (created_at, updated_at, actor_id, actor_username, action, target_type, target_id, detail) + VALUES (now(), now(), $1, $2, 'ADMIN_CONFIG_UPDATE', $3, $4, $5) + `, + [Number(actor.id), actor.username, targetType, targetId, detail], + ); +} + +async function listTeams(dbQuery: QueryFn) { + const result = await dbQuery( + ` + SELECT + t.id, + t.code, + t.name, + t.active, + COALESCE(array_agg(tdr.role ORDER BY tdr.role) FILTER (WHERE tdr.role IS NOT NULL), '{}') AS default_roles + FROM teams t + LEFT JOIN team_default_roles tdr ON tdr.team_id = t.id + GROUP BY t.id + ORDER BY t.name ASC + `, + ); + return result.rows.map(toTeam); +} + +async function listUsers(dbQuery: QueryFn) { + const result = await dbQuery( + ` + SELECT + u.id, + u.username, + u.full_name, + u.department, + u.team_id, + t.code AS team_code, + t.name AS team_name, + u.email, + u.enabled, + u.locked, + COALESCE(array_agg(ur.role ORDER BY ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles + FROM users u + LEFT JOIN teams t ON t.id = u.team_id + LEFT JOIN user_roles ur ON ur.user_id = u.id + GROUP BY u.id, t.id + ORDER BY u.username ASC + `, + ); + return result.rows.map(toAdminUser); +} + +async function setTeamRoles(dbQuery: QueryFn, teamId: number, roles: string[]) { + await dbQuery('DELETE FROM team_default_roles WHERE team_id = $1', [teamId]); + for (const role of roles) { + await dbQuery('INSERT INTO team_default_roles (team_id, role) VALUES ($1, $2)', [teamId, role]); + } +} + +async function setUserRoles(dbQuery: QueryFn, userId: number, roles: string[]) { + await dbQuery('DELETE FROM user_roles WHERE user_id = $1', [userId]); + for (const role of roles) { + await dbQuery('INSERT INTO user_roles (user_id, role) VALUES ($1, $2)', [userId, role]); + } +} + +async function findTeamById(dbQuery: QueryFn, id: number): Promise { + const result = await dbQuery( + ` + SELECT + t.id, t.code, t.name, t.active, + COALESCE(array_agg(tdr.role ORDER BY tdr.role) FILTER (WHERE tdr.role IS NOT NULL), '{}') AS default_roles + FROM teams t + LEFT JOIN team_default_roles tdr ON tdr.team_id = t.id + WHERE t.id = $1 + GROUP BY t.id + `, + [id], + ); + return result.rows[0]; +} + +async function findUserRowById(dbQuery: QueryFn, id: number): Promise { + const result = await dbQuery( + ` + SELECT + u.id, u.username, u.full_name, u.department, u.team_id, + t.code AS team_code, t.name AS team_name, + u.email, u.enabled, u.locked, + COALESCE(array_agg(ur.role ORDER BY ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles + FROM users u + LEFT JOIN teams t ON t.id = u.team_id + LEFT JOIN user_roles ur ON ur.user_id = u.id + WHERE u.id = $1 + GROUP BY u.id, t.id + `, + [id], + ); + return result.rows[0]; +} + +function requiredString(value: unknown, label: string): string { + const text = String(value ?? '').trim(); + if (!text) { + throw new ApiError(400, `${label}은(는) 필수입니다.`); + } + return text; +} + +function roleDisplay(roles: string[]) { + return `[${roles.join(', ')}]`; +} + +async function workbookFromBuffer(file?: Express.Multer.File): Promise { + if (!file) { + throw new ApiError(400, '업로드할 파일을 선택하세요.'); + } + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(file.buffer as unknown as ExcelJS.Buffer); + return workbook; +} + +function cellText(row: ExcelJS.Row, index: number): string { + const value = row.getCell(index).value; + if (value == null) return ''; + if (typeof value === 'object' && 'text' in value) return String(value.text ?? '').trim(); + if (typeof value === 'object' && 'result' in value) return String(value.result ?? '').trim(); + return String(value).trim(); +} + +function splitRoles(raw: string, errors: string[]): string[] { + const roles: string[] = []; + for (const token of raw.split(/[,;/\s]+/)) { + if (!token.trim()) continue; + try { + roles.push(parseRole(token)); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } + return [...new Set(roles)]; +} + +async function teamImportResult(dbQuery: QueryFn, file?: Express.Multer.File): Promise<{ + result: ImportResult; + rows: Array<{ rowNumber: number; code: string; name: string; roles: string[]; errors: string[] }>; +}> { + const workbook = await workbookFromBuffer(file); + const sheet = workbook.worksheets[0]; + const parsed: Array<{ rowNumber: number; code: string; name: string; roles: string[]; errors: string[] }> = []; + const seen = new Set(); + + sheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return; + const code = cellText(row, 1).toUpperCase(); + const name = cellText(row, 2); + const errors: string[] = []; + const roles = splitRoles(cellText(row, 3), errors); + if (!code && !name && roles.length === 0) return; + if (!code) errors.push('팀 코드은(는) 필수입니다.'); + if (!name) errors.push('팀명은(는) 필수입니다.'); + if (roles.length === 0) errors.push('기본권한은 최소 1개 이상 필요합니다.'); + if (seen.has(code)) errors.push('파일 안에 같은 팀 코드가 중복되었습니다.'); + seen.add(code); + parsed.push({ rowNumber, code, name, roles, errors }); + }); + + const result: ImportResult = { + totalRows: parsed.length, + createCount: 0, + updateCount: 0, + errorCount: 0, + warningCount: 0, + applied: false, + success: true, + rows: [], + }; + + for (const row of parsed) { + const codeResult = row.code + ? await dbQuery<{ id: string }>('SELECT id FROM teams WHERE upper(code) = upper($1)', [row.code]) + : { rows: [] }; + const nameResult = row.name + ? await dbQuery<{ code: string }>('SELECT code FROM teams WHERE name = $1 AND upper(code) <> upper($2)', [row.name, row.code]) + : { rows: [] }; + if (nameResult.rows[0]) { + row.errors.push(`이미 다른 팀 코드에서 사용하는 팀명입니다: ${nameResult.rows[0].code}`); + } + const exists = codeResult.rows.length > 0; + if (row.errors.length === 0) { + if (exists) result.updateCount += 1; + else result.createCount += 1; + } + result.rows.push({ + rowNumber: row.rowNumber, + status: row.errors.length > 0 ? 'ERROR' : exists ? 'UPDATE' : 'CREATE', + key: row.code, + summary: `${row.name} / ${roleDisplay(row.roles)}`, + errors: row.errors, + warnings: [], + }); + } + result.errorCount = result.rows.reduce((sum, row) => sum + row.errors.length, 0); + result.success = result.errorCount === 0; + if (!result.success) { + result.createCount = 0; + result.updateCount = 0; + } + return { result, rows: parsed }; +} + +async function userRoleImportResult(dbQuery: QueryFn, file?: Express.Multer.File): Promise<{ + result: ImportResult; + rows: Array<{ rowNumber: number; username: string; fullName: string; teamName: string; roles: string[]; errors: string[] }>; +}> { + const workbook = await workbookFromBuffer(file); + const sheet = workbook.worksheets[0]; + const parsed: Array<{ rowNumber: number; username: string; fullName: string; teamName: string; roles: string[]; errors: string[] }> = []; + const seen = new Set(); + + sheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return; + const username = cellText(row, 1); + const fullName = cellText(row, 2); + const teamName = cellText(row, 3); + const errors: string[] = []; + const roles = splitRoles(cellText(row, 4), errors); + if (!username && !fullName && !teamName && roles.length === 0) return; + if (!username) errors.push('아이디은(는) 필수입니다.'); + if (!fullName) errors.push('이름은(는) 필수입니다.'); + if (!teamName) errors.push('팀명은(는) 필수입니다.'); + if (roles.length === 0) errors.push('권한은 최소 1개 이상 필요합니다.'); + if (seen.has(username.toLowerCase())) errors.push('파일 안에 같은 아이디가 중복되었습니다.'); + seen.add(username.toLowerCase()); + parsed.push({ rowNumber, username, fullName, teamName, roles, errors }); + }); + + const result: ImportResult = { + totalRows: parsed.length, + createCount: 0, + updateCount: 0, + errorCount: 0, + warningCount: 0, + applied: false, + success: true, + rows: [], + }; + + for (const row of parsed) { + const userResult = row.username + ? await dbQuery<{ id: string; full_name: string }>('SELECT id, full_name FROM users WHERE username = $1', [row.username]) + : { rows: [] }; + const teamResult = row.teamName + ? await dbQuery<{ id: string; active: boolean }>('SELECT id, active FROM teams WHERE name = $1', [row.teamName]) + : { rows: [] }; + const user = userResult.rows[0]; + const team = teamResult.rows[0]; + if (!user) row.errors.push('존재하지 않는 아이디입니다.'); + else if (user.full_name !== row.fullName) row.errors.push(`등록된 이름과 일치하지 않습니다: ${user.full_name}`); + if (!team) row.errors.push('존재하지 않는 팀명입니다.'); + else if (!team.active) row.errors.push('사용 중지된 팀입니다.'); + if (row.errors.length === 0) result.updateCount += 1; + result.rows.push({ + rowNumber: row.rowNumber, + status: row.errors.length > 0 ? 'ERROR' : 'UPDATE', + key: row.username, + summary: `${row.fullName} / ${row.teamName} / ${roleDisplay(row.roles)}`, + errors: row.errors, + warnings: [], + }); + } + result.errorCount = result.rows.reduce((sum, row) => sum + row.errors.length, 0); + result.success = result.errorCount === 0; + if (!result.success) result.updateCount = 0; + return { result, rows: parsed }; +} + +async function sendTemplate(res: Response, filename: string, headers: string[], samples: string[][]) { + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet('template'); + sheet.addRow(headers); + for (const sample of samples) sheet.addRow(sample); + sheet.getRow(1).font = { bold: true }; + sheet.columns.forEach((column) => { + column.width = 18; + }); + const buffer = await workbook.xlsx.writeBuffer(); + res + .status(200) + .setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + .setHeader('Content-Disposition', `attachment; filename="${filename}"`) + .send(Buffer.from(buffer)); +} + +export function createAdminRouter(deps: AdminRouterDeps = { query }): Router { + const router = Router(); + const dbQuery = deps.query; + + router.get('/purpose-codes', asyncRoute(async (req, res) => { + await requireAdmin(dbQuery, req); + const result = await dbQuery('SELECT * FROM purpose_codes ORDER BY sort_order ASC, name ASC'); + ok(res, result.rows.map(toPurpose)); + })); + + router.post('/purpose-codes', asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + const code = requiredString(req.body?.code, '코드').toUpperCase(); + const name = requiredString(req.body?.name, '표시명'); + const sortOrder = Number(req.body?.sortOrder ?? 100); + const active = Boolean(req.body?.active); + const customAllowed = Boolean(req.body?.customAllowed); + const result = await dbQuery( + ` + INSERT INTO purpose_codes (created_at, updated_at, code, name, sort_order, active, custom_allowed) + VALUES (now(), now(), $1, $2, $3, $4, $5) + RETURNING * + `, + [code, name, sortOrder, active, customAllowed], + ); + await audit(dbQuery, actor, 'PURPOSE_CODE', Number(result.rows[0].id), `출입목적 코드 추가: ${code}/${name}`); + ok(res, toPurpose(result.rows[0])); + })); + + router.put('/purpose-codes/:id', asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + const id = Number(req.params.id); + const code = requiredString(req.body?.code, '코드').toUpperCase(); + const name = requiredString(req.body?.name, '표시명'); + const result = await dbQuery( + ` + UPDATE purpose_codes + SET code = $1, name = $2, sort_order = $3, active = $4, custom_allowed = $5, updated_at = now() + WHERE id = $6 + RETURNING * + `, + [code, name, Number(req.body?.sortOrder ?? 100), Boolean(req.body?.active), Boolean(req.body?.customAllowed), id], + ); + if (!result.rows[0]) throw new ApiError(404, '출입목적 코드를 찾을 수 없습니다.'); + await audit(dbQuery, actor, 'PURPOSE_CODE', id, `출입목적 코드 수정: ${code}/${name}`); + ok(res, toPurpose(result.rows[0])); + })); + + router.get('/settings/watcher1', asyncRoute(async (req, res) => { + await requireAdmin(dbQuery, req); + ok(res, await watcher1(dbQuery)); + })); + + router.put('/settings/watcher1', asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + await setSetting(dbQuery, 'watcher1.name', requiredString(req.body?.name, '이름')); + await setSetting(dbQuery, 'watcher1.team', requiredString(req.body?.team, '소속')); + await setSetting(dbQuery, 'watcher1.contact', requiredString(req.body?.contact, '연락처')); + await audit(dbQuery, actor, 'SYSTEM_SETTING', null, `현장감시자1 설정 수정: ${req.body.name}/${req.body.team}`); + ok(res, await watcher1(dbQuery)); + })); + + router.get('/teams', asyncRoute(async (req, res) => { + await requireAdmin(dbQuery, req); + ok(res, await listTeams(dbQuery)); + })); + + router.post('/teams', asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + const code = requiredString(req.body?.code, '팀 코드').toUpperCase(); + const name = requiredString(req.body?.name, '팀명'); + const roles = parseRoles(req.body?.defaultRoles); + const result = await dbQuery( + ` + INSERT INTO teams (created_at, updated_at, code, name, active) + VALUES (now(), now(), $1, $2, $3) + RETURNING id, code, name, active, '{}'::varchar[] AS default_roles + `, + [code, name, Boolean(req.body?.active)], + ); + await setTeamRoles(dbQuery, Number(result.rows[0].id), roles); + await audit(dbQuery, actor, 'TEAM', Number(result.rows[0].id), `팀 추가: ${code}/${name}`); + ok(res, toTeam((await findTeamById(dbQuery, Number(result.rows[0].id)))!)); + })); + + router.put('/teams/:id', asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + const id = Number(req.params.id); + const code = requiredString(req.body?.code, '팀 코드').toUpperCase(); + const name = requiredString(req.body?.name, '팀명'); + const roles = parseRoles(req.body?.defaultRoles); + const old = await findTeamById(dbQuery, id); + if (!old) throw new ApiError(404, '팀을 찾을 수 없습니다.'); + const result = await dbQuery( + ` + UPDATE teams SET code = $1, name = $2, active = $3, updated_at = now() + WHERE id = $4 + RETURNING id, code, name, active, '{}'::varchar[] AS default_roles + `, + [code, name, Boolean(req.body?.active), id], + ); + await setTeamRoles(dbQuery, id, roles); + if (old.name !== name) { + await dbQuery('UPDATE users SET department = $1, updated_at = now() WHERE team_id = $2', [name, id]); + } + await audit(dbQuery, actor, 'TEAM', id, `팀 수정: ${code}/${name}`); + ok(res, toTeam((await findTeamById(dbQuery, Number(result.rows[0].id)))!)); + })); + + router.post('/teams/:id/apply-default-roles', asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + const id = Number(req.params.id); + const team = await findTeamById(dbQuery, id); + if (!team) throw new ApiError(404, '팀을 찾을 수 없습니다.'); + const roles = team.default_roles ?? []; + if (roles.length === 0) throw new ApiError(400, '팀 기본권한이 비어 있습니다.'); + const users = await dbQuery<{ id: string }>('SELECT id FROM users WHERE team_id = $1 ORDER BY username', [id]); + for (const user of users.rows) { + await setUserRoles(dbQuery, Number(user.id), roles); + } + await audit(dbQuery, actor, 'TEAM', id, `팀원 전체 기본권한 적용: ${team.name} (${users.rows.length}명)`); + ok(res, `${users.rows.length}명의 팀원에게 기본권한을 적용했습니다.`); + })); + + router.get('/teams/template', asyncRoute(async (req, res) => { + await requireAdmin(dbQuery, req); + await sendTemplate(res, 'admin-teams-template.xlsx', ['팀 코드', '팀명', '기본권한'], [ + ['DEV2', '개발2팀', 'HOST'], + ['SEC', '보안팀', 'SECURITY'], + ]); + })); + + router.post('/teams/upload', upload.single('file'), asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + const dryRun = String(req.query.dryRun ?? 'true') !== 'false'; + const { result, rows } = await teamImportResult(dbQuery, req.file); + if (!dryRun && result.success) { + for (const row of rows) { + const existing = await dbQuery<{ id: string }>('SELECT id FROM teams WHERE upper(code) = upper($1)', [row.code]); + let teamId: number; + if (existing.rows[0]) { + teamId = Number(existing.rows[0].id); + await dbQuery('UPDATE teams SET name = $1, updated_at = now() WHERE id = $2', [row.name, teamId]); + } else { + const inserted = await dbQuery<{ id: string }>( + 'INSERT INTO teams (created_at, updated_at, code, name, active) VALUES (now(), now(), $1, $2, TRUE) RETURNING id', + [row.code, row.name], + ); + teamId = Number(inserted.rows[0].id); + } + await setTeamRoles(dbQuery, teamId, row.roles); + } + result.applied = true; + await audit(dbQuery, actor, 'TEAM', null, `팀 엑셀 업로드: 신규 ${result.createCount}건, 수정 ${result.updateCount}건`); + } + ok(res, result); + })); + + router.get('/users', asyncRoute(async (req, res) => { + await requireAdmin(dbQuery, req); + ok(res, await listUsers(dbQuery)); + })); + + router.put('/users/:id/team', asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + const id = Number(req.params.id); + const teamId = Number(req.body?.teamId); + const team = await findTeamById(dbQuery, teamId); + if (!team) throw new ApiError(404, '팀을 찾을 수 없습니다.'); + await dbQuery('UPDATE users SET team_id = $1, department = $2, updated_at = now() WHERE id = $3', [teamId, team.name, id]); + if (Boolean(req.body?.applyDefaultRoles)) await setUserRoles(dbQuery, id, team.default_roles ?? []); + await audit(dbQuery, actor, 'USER', id, `팀 수정: ${id} -> ${team.name}`); + ok(res, toAdminUser((await findUserRowById(dbQuery, id))!)); + })); + + router.put('/users/:id/roles', asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + const id = Number(req.params.id); + const roles = parseRoles(req.body?.roles); + await setUserRoles(dbQuery, id, roles); + await audit(dbQuery, actor, 'USER', id, `권한 수정: ${id} -> ${roles.join(',')}`); + ok(res, toAdminUser((await findUserRowById(dbQuery, id))!)); + })); + + router.post('/users/:id/apply-team-default-roles', asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + const user = await findUserRowById(dbQuery, Number(req.params.id)); + if (!user || !user.team_id) throw new ApiError(400, '사용자에게 팀이 지정되어 있지 않습니다.'); + const team = await findTeamById(dbQuery, Number(user.team_id)); + if (!team) throw new ApiError(404, '팀을 찾을 수 없습니다.'); + await setUserRoles(dbQuery, Number(user.id), team.default_roles ?? []); + await audit(dbQuery, actor, 'USER', Number(user.id), `팀 기본권한 적용: ${user.username} -> ${team.name}`); + ok(res, toAdminUser((await findUserRowById(dbQuery, Number(user.id)))!)); + })); + + router.get('/users/roles/template', asyncRoute(async (req, res) => { + await requireAdmin(dbQuery, req); + await sendTemplate(res, 'admin-user-roles-template.xlsx', ['아이디', '이름', '팀명', '권한'], [ + ['h', '홍길동', '개발1팀', 'HOST'], + ['s', '보안담당', '보안팀', 'SECURITY'], + ]); + })); + + router.post('/users/roles/upload', upload.single('file'), asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + const dryRun = String(req.query.dryRun ?? 'true') !== 'false'; + const { result, rows } = await userRoleImportResult(dbQuery, req.file); + if (!dryRun && result.success) { + for (const row of rows) { + const user = await dbQuery<{ id: string }>('SELECT id FROM users WHERE username = $1', [row.username]); + const team = await dbQuery<{ id: string }>('SELECT id FROM teams WHERE name = $1', [row.teamName]); + await dbQuery('UPDATE users SET team_id = $1, department = $2, updated_at = now() WHERE id = $3', [ + Number(team.rows[0].id), + row.teamName, + Number(user.rows[0].id), + ]); + await setUserRoles(dbQuery, Number(user.rows[0].id), row.roles); + } + result.applied = true; + await audit(dbQuery, actor, 'USER', null, `권한 엑셀 업로드: 수정 ${result.updateCount}건`); + } + ok(res, result); + })); + + return router; +} + +async function setting(dbQuery: QueryFn, key: string, fallback: string): Promise { + const result = await dbQuery<{ setting_value: string | null }>('SELECT setting_value FROM system_settings WHERE setting_key = $1', [key]); + return result.rows[0]?.setting_value || fallback; +} + +async function setSetting(dbQuery: QueryFn, key: string, value: string) { + await dbQuery( + ` + INSERT INTO system_settings (created_at, updated_at, setting_key, setting_value) + VALUES (now(), now(), $1, $2) + ON CONFLICT (setting_key) + DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = now() + `, + [key, value], + ); +} + +export async function watcher1(dbQuery: QueryFn) { + return { + name: await setting(dbQuery, 'watcher1.name', '류관순'), + team: await setting(dbQuery, 'watcher1.team', 'IT전략국'), + contact: await setting(dbQuery, 'watcher1.contact', '313'), + }; +} + +export function createConfigRouter(deps: AdminRouterDeps = { query }): Router { + const router = Router(); + const dbQuery = deps.query; + + router.get('/purpose-codes', asyncRoute(async (_req, res) => { + const result = await dbQuery( + 'SELECT * FROM purpose_codes WHERE active = TRUE ORDER BY sort_order ASC, name ASC', + ); + ok(res, result.rows.map(toPurpose)); + })); + + router.get('/settings/watcher1', asyncRoute(async (req, res) => { + await requireCurrentUser(dbQuery, req); + ok(res, await watcher1(dbQuery)); + })); + + return router; +} + +export const adminRouter = createAdminRouter(); +export const configRouter = createConfigRouter(); diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts new file mode 100644 index 0000000..a69985f --- /dev/null +++ b/server/routes/auth.test.ts @@ -0,0 +1,195 @@ +import assert from 'node:assert/strict'; +import type { AddressInfo } from 'node:net'; +import test from 'node:test'; +import bcrypt from 'bcryptjs'; +import express from 'express'; +import { createAuthRouter } from './auth.js'; +import { errorHandler } from '../http/errors.js'; + +type QueryFn = NonNullable[0]>['query']; +type JsonBody = { + code: number; + message: string; + data: any; +}; + +interface UserRow { + id: string; + username: string; + password_hash: string; + full_name: string; + email: string | null; + department: string | null; + must_change_password: boolean; + enabled: boolean; + locked: boolean; + roles: string[]; +} + +function queryResult(rows: T[]) { + return { + command: 'SELECT', + rowCount: rows.length, + oid: 0, + fields: [], + rows, + }; +} + +function buildApp(query: QueryFn, sessionData: Record = {}) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.session = { + ...sessionData, + destroy(callback: (error?: Error) => void) { + callback(); + }, + } as typeof req.session; + next(); + }); + app.use('/auth', createAuthRouter({ query })); + app.use(errorHandler); + return app; +} + +async function requestJson( + app: ReturnType, + method: string, + path: string, + body?: unknown, +) { + const server = app.listen(0); + try { + const address = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${address.port}${path}`, { + method, + headers: body === undefined ? undefined : { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { + status: response.status, + body: await response.json() as JsonBody, + }; + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } +} + +function makeQuery(user: UserRow, calls: Array<{ text: string; params?: unknown[] }>): QueryFn { + return (async (text, params) => { + const sql = String(text); + calls.push({ text: sql, params }); + + if (sql.includes('WHERE u.username = $1')) { + return queryResult(params?.[0] === user.username ? [user] : []); + } + + if (sql.includes('WHERE u.id = $1')) { + return queryResult(Number(params?.[0]) === Number(user.id) ? [user] : []); + } + + if (sql.includes('UPDATE users')) { + return queryResult([]); + } + + throw new Error(`Unexpected query: ${sql}`); + }) as QueryFn; +} + +test('login trims username and returns the current user shape', async () => { + const user: UserRow = { + id: '1', + username: 'a', + password_hash: await bcrypt.hash('1', 4), + full_name: 'ACS Admin', + email: 'admin@example.test', + department: 'IT', + must_change_password: false, + enabled: true, + locked: false, + roles: ['ADMIN'], + }; + const calls: Array<{ text: string; params?: unknown[] }> = []; + const app = buildApp(makeQuery(user, calls)); + + const response = await requestJson(app, 'POST', '/auth/login', { + username: ' a ', + password: '1', + }); + + assert.equal(response.status, 200); + assert.equal(response.body.data.username, 'a'); + assert.equal(response.body.data.fullName, 'ACS Admin'); + assert.deepEqual(response.body.data.roles, ['ADMIN']); + assert.deepEqual(calls[0]?.params, ['a']); +}); + +test('login rejects disabled accounts even with a valid password', async () => { + const user: UserRow = { + id: '2', + username: 'disabled', + password_hash: await bcrypt.hash('1', 4), + full_name: 'Disabled User', + email: null, + department: null, + must_change_password: false, + enabled: false, + locked: false, + roles: ['HOST'], + }; + const app = buildApp(makeQuery(user, [])); + + const response = await requestJson(app, 'POST', '/auth/login', { + username: 'disabled', + password: '1', + }); + + assert.equal(response.status, 401); + assert.equal(response.body.data, null); +}); + +test('me returns 401 without a session user', async () => { + const app = buildApp((async () => { + throw new Error('query should not run for unauthenticated /me'); + }) as QueryFn); + + const response = await requestJson(app, 'GET', '/auth/me'); + + assert.equal(response.status, 401); + assert.equal(response.body.data, null); +}); + +test('change-password updates the password hash and clears must_change_password', async () => { + const user: UserRow = { + id: '3', + username: 'host', + password_hash: await bcrypt.hash('OldPassword123!', 4), + full_name: 'Host User', + email: null, + department: null, + must_change_password: true, + enabled: true, + locked: false, + roles: ['HOST'], + }; + const calls: Array<{ text: string; params?: unknown[] }> = []; + const app = buildApp(makeQuery(user, calls), { userId: 3 }); + + const response = await requestJson(app, 'POST', '/auth/change-password', { + oldPassword: 'OldPassword123!', + newPassword: 'NewPassword123!', + }); + + assert.equal(response.status, 200); + const updateCall = calls.find((call) => call.text.includes('UPDATE users')); + assert.ok(updateCall); + assert.equal(updateCall.params?.[1], 3); + assert.equal(await bcrypt.compare('NewPassword123!', String(updateCall.params?.[0])), true); + assert.notEqual(updateCall.params?.[0], user.password_hash); +}); diff --git a/server/routes/auth.ts b/server/routes/auth.ts index db6cd23..aa3166e 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -4,19 +4,7 @@ import { Router } from 'express'; import { ok } from '../http/apiResponse.js'; import { ApiError } from '../http/errors.js'; import { query } from '../db/pool.js'; - -interface UserRow { - id: string; - username: string; - password_hash: string; - full_name: string; - email: string | null; - department: string | null; - must_change_password: boolean; - enabled: boolean; - locked: boolean; - roles: string[]; -} +import { findUserById, findUserByUsername, requireCurrentUser, type QueryFn, type UserRow } from './security.js'; interface CurrentUser { id: number; @@ -28,7 +16,9 @@ interface CurrentUser { mustChangePassword: boolean; } -const router = Router(); +interface AuthRouterDeps { + query: QueryFn; +} function asyncRoute( handler: (req: Request, res: Response, next: NextFunction) => Promise, @@ -38,54 +28,6 @@ function asyncRoute( }; } -async function findUserByUsername(username: string): Promise { - const result = await query( - ` - SELECT - u.id, - u.username, - u.password_hash, - u.full_name, - u.email, - u.department, - u.must_change_password, - u.enabled, - u.locked, - COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles - FROM users u - LEFT JOIN user_roles ur ON ur.user_id = u.id - WHERE u.username = $1 - GROUP BY u.id - `, - [username], - ); - return result.rows[0]; -} - -async function findUserById(id: number): Promise { - const result = await query( - ` - SELECT - u.id, - u.username, - u.password_hash, - u.full_name, - u.email, - u.department, - u.must_change_password, - u.enabled, - u.locked, - COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles - FROM users u - LEFT JOIN user_roles ur ON ur.user_id = u.id - WHERE u.id = $1 - GROUP BY u.id - `, - [id], - ); - return result.rows[0]; -} - function toCurrentUser(user: UserRow): CurrentUser { return { id: Number(user.id), @@ -98,168 +40,160 @@ function toCurrentUser(user: UserRow): CurrentUser { }; } -async function requireCurrentUser(req: Request): Promise { - const userId = req.session.userId; - if (!userId) { - throw new ApiError(401, '로그인이 필요합니다.'); - } +export function createAuthRouter(deps: AuthRouterDeps = { query }): Router { + const router = Router(); + const dbQuery = deps.query; - const user = await findUserById(userId); - if (!user || !user.enabled || user.locked) { - req.session.userId = undefined; - throw new ApiError(401, '로그인이 필요합니다.'); - } + router.post('/login', asyncRoute(async (req, res) => { + const username = String(req.body?.username ?? '').trim(); + const password = String(req.body?.password ?? ''); - return user; + if (!username || !password) { + throw new ApiError(400, '아이디와 비밀번호를 입력하세요.'); + } + + const user = await findUserByUsername(dbQuery, username); + const matches = user ? await bcrypt.compare(password, user.password_hash) : false; + if (!user || !matches || !user.enabled || user.locked) { + throw new ApiError(401, '아이디 또는 비밀번호가 올바르지 않습니다.'); + } + + req.session.userId = Number(user.id); + ok(res, toCurrentUser(user)); + })); + + router.post('/logout', (req, res, next) => { + req.session.destroy((error) => { + if (error) { + next(error); + return; + } + res.clearCookie('connect.sid'); + ok(res, '로그아웃되었습니다.'); + }); + }); + + router.get('/me', asyncRoute(async (req, res) => { + const user = await requireCurrentUser(dbQuery, req); + ok(res, toCurrentUser(user)); + })); + + router.post('/change-password', asyncRoute(async (req, res) => { + const user = await requireCurrentUser(dbQuery, req); + const oldPassword = String(req.body?.oldPassword ?? ''); + const newPassword = String(req.body?.newPassword ?? ''); + + if (newPassword.length < 8) { + throw new ApiError(400, '새 비밀번호는 8자 이상이어야 합니다.'); + } + + if (!(await bcrypt.compare(oldPassword, user.password_hash))) { + throw new ApiError(400, '현재 비밀번호가 올바르지 않습니다.'); + } + + if (await bcrypt.compare(newPassword, user.password_hash)) { + throw new ApiError(400, '기존 비밀번호와 다른 비밀번호를 입력하세요.'); + } + + const passwordHash = await bcrypt.hash(newPassword, 12); + await dbQuery( + ` + UPDATE users + SET password_hash = $1, + must_change_password = FALSE, + updated_at = now() + WHERE id = $2 + `, + [passwordHash, Number(user.id)], + ); + + ok(res, '비밀번호가 변경되었습니다.'); + })); + + router.post('/diagnostics', asyncRoute(async (_req, res) => { + const tableResult = await dbQuery<{ + current_schema: string; + users_table: string | null; + user_roles_table: string | null; + sessions_table: string | null; + migrations_table: string | null; + }>( + ` + SELECT + current_schema() AS current_schema, + to_regclass('users')::text AS users_table, + to_regclass('user_roles')::text AS user_roles_table, + to_regclass('user_sessions')::text AS sessions_table, + to_regclass('schema_migrations')::text AS migrations_table + `, + ); + const tables = tableResult.rows[0]; + let users: Array<{ + username: string; + enabled: boolean; + locked: boolean; + must_change_password: boolean; + password_hash_length: number; + roles: string[]; + }> = []; + let userQueryError: string | undefined; + let migrations: string[] = []; + let migrationQueryError: string | undefined; + + if (tables?.users_table && tables.user_roles_table) { + try { + const userResult = await dbQuery<{ + username: string; + enabled: boolean; + locked: boolean; + must_change_password: boolean; + password_hash_length: number; + roles: string[]; + }>( + ` + SELECT + u.username, + u.enabled, + u.locked, + u.must_change_password, + length(u.password_hash) AS password_hash_length, + COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles + FROM users u + LEFT JOIN user_roles ur ON ur.user_id = u.id + WHERE u.username IN ('admin', 'security', 'host', 'a', 's', 'h') + GROUP BY u.id + ORDER BY u.username + `, + ); + users = userResult.rows; + } catch (error) { + userQueryError = error instanceof Error ? error.message : String(error); + } + } + + if (tables?.migrations_table) { + try { + const migrationResult = await dbQuery<{ filename: string }>( + 'SELECT filename FROM schema_migrations ORDER BY filename', + ); + migrations = migrationResult.rows.map((row) => row.filename); + } catch (error) { + migrationQueryError = error instanceof Error ? error.message : String(error); + } + } + + ok(res, { + authApi: true, + checkedAt: new Date().toISOString(), + tables, + users, + userQueryError, + migrations, + migrationQueryError, + }); + })); + + return router; } -router.post('/login', asyncRoute(async (req, res) => { - const username = String(req.body?.username ?? '').trim(); - const password = String(req.body?.password ?? ''); - - if (!username || !password) { - throw new ApiError(400, '아이디와 비밀번호를 입력하세요.'); - } - - const user = await findUserByUsername(username); - const matches = user ? await bcrypt.compare(password, user.password_hash) : false; - if (!user || !matches || !user.enabled || user.locked) { - throw new ApiError(401, '아이디 또는 비밀번호가 올바르지 않습니다.'); - } - - req.session.userId = Number(user.id); - ok(res, toCurrentUser(user)); -})); - -router.post('/logout', (req, res, next) => { - req.session.destroy((error) => { - if (error) { - next(error); - return; - } - res.clearCookie('connect.sid'); - ok(res, '로그아웃되었습니다.'); - }); -}); - -router.get('/me', asyncRoute(async (req, res) => { - const user = await requireCurrentUser(req); - ok(res, toCurrentUser(user)); -})); - -router.post('/change-password', asyncRoute(async (req, res) => { - const user = await requireCurrentUser(req); - const oldPassword = String(req.body?.oldPassword ?? ''); - const newPassword = String(req.body?.newPassword ?? ''); - - if (newPassword.length < 8) { - throw new ApiError(400, '새 비밀번호는 8자 이상이어야 합니다.'); - } - - if (!(await bcrypt.compare(oldPassword, user.password_hash))) { - throw new ApiError(400, '현재 비밀번호가 올바르지 않습니다.'); - } - - if (await bcrypt.compare(newPassword, user.password_hash)) { - throw new ApiError(400, '기존 비밀번호와 다른 비밀번호를 입력하세요.'); - } - - const passwordHash = await bcrypt.hash(newPassword, 12); - await query( - ` - UPDATE users - SET password_hash = $1, - must_change_password = FALSE, - updated_at = now() - WHERE id = $2 - `, - [passwordHash, Number(user.id)], - ); - - ok(res, '비밀번호가 변경되었습니다.'); -})); - -router.post('/diagnostics', asyncRoute(async (_req, res) => { - const tableResult = await query<{ - current_schema: string; - users_table: string | null; - user_roles_table: string | null; - sessions_table: string | null; - migrations_table: string | null; - }>( - ` - SELECT - current_schema() AS current_schema, - to_regclass('users')::text AS users_table, - to_regclass('user_roles')::text AS user_roles_table, - to_regclass('user_sessions')::text AS sessions_table, - to_regclass('schema_migrations')::text AS migrations_table - `, - ); - const tables = tableResult.rows[0]; - let users: Array<{ - username: string; - enabled: boolean; - locked: boolean; - must_change_password: boolean; - password_hash_length: number; - roles: string[]; - }> = []; - let userQueryError: string | undefined; - let migrations: string[] = []; - let migrationQueryError: string | undefined; - - if (tables?.users_table && tables.user_roles_table) { - try { - const userResult = await query<{ - username: string; - enabled: boolean; - locked: boolean; - must_change_password: boolean; - password_hash_length: number; - roles: string[]; - }>( - ` - SELECT - u.username, - u.enabled, - u.locked, - u.must_change_password, - length(u.password_hash) AS password_hash_length, - COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles - FROM users u - LEFT JOIN user_roles ur ON ur.user_id = u.id - WHERE u.username IN ('admin', 'security', 'host', 'a', 's', 'h') - GROUP BY u.id - ORDER BY u.username - `, - ); - users = userResult.rows; - } catch (error) { - userQueryError = error instanceof Error ? error.message : String(error); - } - } - - if (tables?.migrations_table) { - try { - const migrationResult = await query<{ filename: string }>( - 'SELECT filename FROM schema_migrations ORDER BY filename', - ); - migrations = migrationResult.rows.map((row) => row.filename); - } catch (error) { - migrationQueryError = error instanceof Error ? error.message : String(error); - } - } - - ok(res, { - authApi: true, - checkedAt: new Date().toISOString(), - tables, - users, - userQueryError, - migrations, - migrationQueryError, - }); -})); - -export const authRouter = router; +export const authRouter = createAuthRouter(); diff --git a/server/routes/business.ts b/server/routes/business.ts new file mode 100644 index 0000000..ef11336 --- /dev/null +++ b/server/routes/business.ts @@ -0,0 +1,706 @@ +import type { NextFunction, Request, Response } from 'express'; +import { Router } from 'express'; +import multer from 'multer'; +import ExcelJS from 'exceljs'; +import QRCode from 'qrcode'; +import { randomUUID } from 'node:crypto'; +import { ok } from '../http/apiResponse.js'; +import { ApiError } from '../http/errors.js'; +import { env } from '../config/env.js'; +import { query } from '../db/pool.js'; +import { requireAdmin, requireCurrentUser, type QueryFn, type UserRow } from './security.js'; +import { watcher1 } from './admin.js'; + +interface BusinessRouterDeps { + query: QueryFn; +} + +interface VisitRow { + id: string; + visitor_name: string; + company: string | null; + contact: string | null; + email: string | null; + vehicle_no: string | null; + host_id: string; + host_name: string; + host_department: string | null; + zone_name: string | null; + purpose: string; + purpose_code: string | null; + purpose_detail: string | null; + work_name: string | null; + control_name: string | null; + control_team: string | null; + control_contact: string | null; + watcher1_name: string | null; + watcher1_team: string | null; + watcher1_contact: string | null; + watcher2_name: string | null; + watcher2_team: string | null; + watcher2_contact: string | null; + visit_from: string | Date; + visit_to: string | Date; + status: string; + qr_token: string | null; + created_at: string | Date; +} + +interface AccessRow { + visit_request_id: string; + visitor_name: string; + company: string | null; + zone_name: string | null; + host_name: string; + visit_from: string | Date; + visit_to: string | Date; + check_in_at: string | Date | null; + check_out_at: string | Date | null; +} + +const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 3 * 1024 * 1024 } }); + +function asyncRoute(handler: (req: Request, res: Response, next: NextFunction) => Promise) { + return (req: Request, res: Response, next: NextFunction) => { + handler(req, res, next).catch(next); + }; +} + +function toIso(value: string | Date | null | undefined): string | undefined { + if (value == null) return undefined; + return new Date(value).toISOString(); +} + +function toVisit(row: VisitRow) { + return { + id: Number(row.id), + visitorName: row.visitor_name, + company: row.company ?? undefined, + contact: row.contact ?? undefined, + email: row.email ?? undefined, + vehicleNo: row.vehicle_no ?? undefined, + hostId: Number(row.host_id), + hostName: row.host_name, + hostDepartment: row.host_department ?? undefined, + zoneName: row.zone_name ?? undefined, + purpose: row.purpose, + purposeCode: row.purpose_code ?? undefined, + purposeDetail: row.purpose_detail ?? undefined, + workName: row.work_name ?? undefined, + controlName: row.control_name ?? undefined, + controlTeam: row.control_team ?? undefined, + controlContact: row.control_contact ?? undefined, + watcher1Name: row.watcher1_name ?? undefined, + watcher1Team: row.watcher1_team ?? undefined, + watcher1Contact: row.watcher1_contact ?? undefined, + watcher2Name: row.watcher2_name ?? undefined, + watcher2Team: row.watcher2_team ?? undefined, + watcher2Contact: row.watcher2_contact ?? undefined, + visitFrom: toIso(row.visit_from)!, + visitTo: toIso(row.visit_to)!, + status: row.status, + qrToken: row.qr_token ?? undefined, + createdAt: toIso(row.created_at)!, + }; +} + +function required(value: unknown, label: string): string { + const text = String(value ?? '').trim(); + if (!text) throw new ApiError(400, `${label}을(를) 입력하세요.`); + return text; +} + +function sameDate(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +} + +async function visitById(dbQuery: QueryFn, id: number): Promise { + const result = await dbQuery( + ` + SELECT + vr.id, + v.name AS visitor_name, + v.company, + v.contact, + v.email, + v.vehicle_no, + h.id AS host_id, + h.full_name AS host_name, + h.department AS host_department, + vr.zone_name, + vr.purpose, + vr.purpose_code, + vr.purpose_detail, + vr.work_name, + vr.control_name, + vr.control_team, + vr.control_contact, + vr.watcher1_name, + vr.watcher1_team, + vr.watcher1_contact, + vr.watcher2_name, + vr.watcher2_team, + vr.watcher2_contact, + vr.visit_from, + vr.visit_to, + vr.status, + vr.qr_token, + vr.created_at + FROM visit_requests vr + JOIN visitors v ON v.id = vr.visitor_id + JOIN users h ON h.id = vr.host_id + WHERE vr.id = $1 + `, + [id], + ); + return result.rows[0]; +} + +async function listVisitRows(dbQuery: QueryFn, where = '', params: unknown[] = []) { + const result = await dbQuery( + ` + SELECT + vr.id, + v.name AS visitor_name, + v.company, + v.contact, + v.email, + v.vehicle_no, + h.id AS host_id, + h.full_name AS host_name, + h.department AS host_department, + vr.zone_name, + vr.purpose, + vr.purpose_code, + vr.purpose_detail, + vr.work_name, + vr.control_name, + vr.control_team, + vr.control_contact, + vr.watcher1_name, + vr.watcher1_team, + vr.watcher1_contact, + vr.watcher2_name, + vr.watcher2_team, + vr.watcher2_contact, + vr.visit_from, + vr.visit_to, + vr.status, + vr.qr_token, + vr.created_at + FROM visit_requests vr + JOIN visitors v ON v.id = vr.visitor_id + JOIN users h ON h.id = vr.host_id + ${where} + ORDER BY vr.created_at DESC + `, + params, + ); + return result.rows.map(toVisit); +} + +async function audit(dbQuery: QueryFn, actor: UserRow, action: string, targetType: string, targetId: number | null, detail: string) { + await dbQuery( + ` + INSERT INTO audit_logs (created_at, updated_at, actor_id, actor_username, action, target_type, target_id, detail) + VALUES (now(), now(), $1, $2, $3, $4, $5, $6) + `, + [Number(actor.id), actor.username, action, targetType, targetId, detail], + ); +} + +async function resolvePurpose(dbQuery: QueryFn, code: string | undefined, purpose: string, detail?: string) { + if (code) { + const result = await dbQuery<{ code: string; name: string; custom_allowed: boolean; active: boolean }>( + 'SELECT code, name, custom_allowed, active FROM purpose_codes WHERE upper(code) = upper($1)', + [code], + ); + const item = result.rows[0]; + if (!item || !item.active) throw new ApiError(400, `존재하지 않는 출입목적 코드입니다: ${code}`); + if (!item.custom_allowed) return { code: item.code, name: item.name, detail: null, display: item.name }; + const text = String(detail || purpose || '').trim(); + if (!text || text === item.name) throw new ApiError(400, '기타 출입 목적을 입력하세요.'); + const matched = await dbQuery<{ code: string; name: string }>( + 'SELECT code, name FROM purpose_codes WHERE active = TRUE AND custom_allowed = FALSE AND (upper(code) = upper($1) OR name = $1)', + [text], + ); + if (matched.rows[0]) return { code: matched.rows[0].code, name: matched.rows[0].name, detail: null, display: matched.rows[0].name }; + return { code: item.code, name: text, detail: text, display: text }; + } + const text = required(purpose, '출입 목적'); + return { code: null, name: text, detail: null, display: text }; +} + +async function createOneVisit(dbQuery: QueryFn, actor: UserRow, body: Record, zoneName: string) { + const visitorName = required(body.visitorName, '방문자 이름'); + const contact = required(body.contact, '방문자 연락처'); + const visitFrom = new Date(required(body.visitFrom, '출입 일시')); + const visitTo = new Date(required(body.visitTo, '퇴실 일시')); + if (visitFrom > visitTo) throw new ApiError(400, '퇴장일시는 출입일시 이후로 입력해야 합니다.'); + if (!sameDate(visitFrom, visitTo)) { + throw new ApiError(400, '퇴장일이 출입일 다음날 이후가 되는 경우는 2건으로 신청해야 합니다'); + } + const purpose = await resolvePurpose( + dbQuery, + body.purposeCode ? String(body.purposeCode) : undefined, + String(body.purpose ?? ''), + body.purposeDetail ? String(body.purposeDetail) : undefined, + ); + const watcher = await watcher1(dbQuery); + const visitor = await dbQuery<{ id: string }>( + ` + INSERT INTO visitors (created_at, updated_at, name, company, contact, email, vehicle_no) + VALUES (now(), now(), $1, $2, $3, $4, $5) + RETURNING id + `, + [ + visitorName, + body.company ? String(body.company).trim() : null, + contact, + body.email ? String(body.email).trim() : null, + body.vehicleNo ? String(body.vehicleNo).trim() : null, + ], + ); + const qrToken = randomUUID().replaceAll('-', ''); + const created = await dbQuery<{ id: string }>( + ` + INSERT INTO visit_requests ( + created_at, updated_at, visitor_id, host_id, zone_name, purpose, purpose_code, purpose_detail, + work_name, visit_from, visit_to, status, qr_token, + control_name, control_team, control_contact, + watcher1_name, watcher1_team, watcher1_contact, + watcher2_name, watcher2_team, watcher2_contact + ) + VALUES ( + now(), now(), $1, $2, $3, $4, $5, $6, + $7, $8, $9, 'PENDING', $10, + $11, $12, $13, + $14, $15, $16, + $17, $18, $19 + ) + RETURNING id + `, + [ + Number(visitor.rows[0].id), + Number(actor.id), + zoneName, + purpose.display, + purpose.code, + purpose.detail, + body.workName ? String(body.workName).trim() : null, + visitFrom, + visitTo, + qrToken, + actor.full_name, + actor.department, + null, + watcher.name, + watcher.team, + watcher.contact, + body.watcher2Name ? String(body.watcher2Name).trim() : null, + body.watcher2Team ? String(body.watcher2Team).trim() : null, + body.watcher2Contact ? String(body.watcher2Contact).trim() : null, + ], + ); + return toVisit((await visitById(dbQuery, Number(created.rows[0].id)))!); +} + +export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Router { + const router = Router(); + const dbQuery = deps.query; + + router.get('/zones', asyncRoute(async (req, res) => { + await requireCurrentUser(dbQuery, req); + const result = await dbQuery('SELECT id, code, name, security_level FROM zones WHERE active = TRUE ORDER BY id'); + ok(res, result.rows.map((row) => ({ + id: Number(row.id), + code: row.code, + name: row.name, + securityLevel: row.security_level, + }))); + })); + + router.get('/visit-requests', asyncRoute(async (req, res) => { + await requireCurrentUser(dbQuery, req); + ok(res, await listVisitRows(dbQuery)); + })); + + router.get('/visit-requests/pending', asyncRoute(async (req, res) => { + await requireCurrentUser(dbQuery, req); + ok(res, await listVisitRows(dbQuery, "WHERE vr.status = 'PENDING'")); + })); + + router.get('/visit-requests/:id', asyncRoute(async (req, res) => { + await requireCurrentUser(dbQuery, req); + const visit = await visitById(dbQuery, Number(req.params.id)); + if (!visit) throw new ApiError(404, '방문 신청을 찾을 수 없습니다.'); + ok(res, toVisit(visit)); + })); + + router.post('/visit-requests', asyncRoute(async (req, res) => { + const actor = await requireCurrentUser(dbQuery, req); + const rooms = Array.isArray(req.body?.serverRooms) ? req.body.serverRooms.map(String).filter(Boolean) : []; + const roomZone = req.body?.roomZone ? String(req.body.roomZone).trim() : ''; + const zones = rooms.length > 0 ? rooms : roomZone ? [roomZone] : []; + if (zones.length === 0) throw new ApiError(400, '출입 구역을 선택하세요.'); + const created = []; + for (const zone of zones) { + created.push(await createOneVisit(dbQuery, actor, req.body, zone)); + } + ok(res, created); + })); + + router.post('/visit-requests/:id/cancel', asyncRoute(async (req, res) => { + const actor = await requireCurrentUser(dbQuery, req); + await dbQuery("UPDATE visit_requests SET status = 'CANCELLED', updated_at = now() WHERE id = $1 AND status IN ('PENDING', 'APPROVED')", [Number(req.params.id)]); + await audit(dbQuery, actor, 'REJECT', 'VISIT_REQUEST', Number(req.params.id), '신청 취소'); + ok(res, toVisit((await visitById(dbQuery, Number(req.params.id)))!)); + })); + + router.post('/visit-requests/upload', upload.single('file'), asyncRoute(async (_req, res) => { + ok(res, { totalRows: 0, successCount: 0, errors: ['Node 배포본의 방문자 명단 엑셀 업로드는 다음 단계에서 이관 예정입니다.'], success: false }); + })); + + router.post('/approvals/:id/approve', asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + const id = Number(req.params.id); + await dbQuery("UPDATE visit_requests SET status = 'APPROVED', updated_at = now() WHERE id = $1", [id]); + await dbQuery( + "INSERT INTO approvals (created_at, updated_at, visit_request_id, approver_id, decision, comment, decided_at) VALUES (now(), now(), $1, $2, 'APPROVED', $3, now())", + [id, Number(actor.id), req.body?.comment ? String(req.body.comment) : null], + ); + await dbQuery( + "INSERT INTO pass_deliveries (created_at, updated_at, visit_request_id, channel, recipient, status, attempts, last_error) VALUES (now(), now(), $1, 'DEV', NULL, 'SENT', 1, NULL)", + [id], + ); + await audit(dbQuery, actor, 'APPROVE', 'VISIT_REQUEST', id, '승인'); + ok(res, toVisit((await visitById(dbQuery, id))!)); + })); + + router.post('/approvals/:id/reject', asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + const id = Number(req.params.id); + await dbQuery("UPDATE visit_requests SET status = 'REJECTED', updated_at = now() WHERE id = $1", [id]); + await dbQuery( + "INSERT INTO approvals (created_at, updated_at, visit_request_id, approver_id, decision, comment, decided_at) VALUES (now(), now(), $1, $2, 'REJECTED', $3, now())", + [id, Number(actor.id), req.body?.comment ? String(req.body.comment) : null], + ); + await audit(dbQuery, actor, 'REJECT', 'VISIT_REQUEST', id, '반려'); + ok(res, toVisit((await visitById(dbQuery, id))!)); + })); + + router.get('/passes/:id', asyncRoute(async (req, res) => { + await requireCurrentUser(dbQuery, req); + const visit = await visitById(dbQuery, Number(req.params.id)); + if (!visit) throw new ApiError(404, '출입증을 찾을 수 없습니다.'); + ok(res, toVisit(visit)); + })); + + router.get('/passes/:id/qr.png', asyncRoute(async (req, res) => { + await requireCurrentUser(dbQuery, req); + const visit = await visitById(dbQuery, Number(req.params.id)); + if (!visit?.qr_token) throw new ApiError(404, 'QR을 찾을 수 없습니다.'); + const png = await QRCode.toBuffer(`${env.publicBaseUrl}/pass/${visit.qr_token}`, { type: 'png', width: 220 }); + res.type('png').send(png); + })); + + router.get('/public/passes/:token', asyncRoute(async (req, res) => { + const result = await dbQuery( + ` + SELECT + vr.*, v.name AS visitor_name, v.company, v.contact, v.email, v.vehicle_no, + h.id AS host_id, h.full_name AS host_name, h.department AS host_department, + EXISTS ( + SELECT 1 FROM access_events ae + WHERE ae.visit_request_id = vr.id AND ae.direction = 'IN' + AND NOT EXISTS ( + SELECT 1 FROM access_events out_ae + WHERE out_ae.visit_request_id = vr.id AND out_ae.direction = 'OUT' AND out_ae.event_at > ae.event_at + ) + ) AS inside, + EXISTS ( + SELECT 1 FROM access_events ae + WHERE ae.visit_request_id = vr.id AND ae.direction = 'OUT' AND ae.event_at::date = now()::date + ) AS completed_today + FROM visit_requests vr + JOIN visitors v ON v.id = vr.visitor_id + JOIN users h ON h.id = vr.host_id + WHERE vr.qr_token = $1 + `, + [req.params.token], + ); + const row = result.rows[0]; + if (!row) throw new ApiError(404, '출입증을 찾을 수 없습니다.'); + ok(res, { + visitorName: row.visitor_name, + company: row.company ?? undefined, + zoneName: row.zone_name ?? undefined, + visitFrom: toIso(row.visit_from)!, + visitTo: toIso(row.visit_to)!, + inside: row.inside, + completedToday: row.completed_today, + }); + })); + + router.get('/public/passes/:token/qr.png', asyncRoute(async (req, res) => { + const png = await QRCode.toBuffer(`${env.publicBaseUrl}/pass/${req.params.token}`, { type: 'png', width: 220 }); + res.type('png').send(png); + })); + + async function accessAction(req: Request, res: Response, direction: 'IN' | 'OUT') { + const actor = req.path.startsWith('/public/') ? null : await requireCurrentUser(dbQuery, req); + const token = req.params.token || req.body?.qrToken; + let id = req.body?.visitRequestId ? Number(req.body.visitRequestId) : undefined; + if (!id && token) { + const found = await dbQuery<{ id: string }>('SELECT id FROM visit_requests WHERE qr_token = $1', [String(token)]); + id = found.rows[0] ? Number(found.rows[0].id) : undefined; + } + if (!id) throw new ApiError(404, '방문 신청을 찾을 수 없습니다.'); + const visit = await visitById(dbQuery, id); + if (!visit) throw new ApiError(404, '방문 신청을 찾을 수 없습니다.'); + if (visit.status !== 'APPROVED') throw new ApiError(400, '승인된 방문 신청이 아닙니다.'); + const today = new Date(); + if (!sameDate(today, new Date(visit.visit_from))) { + await dbQuery("UPDATE visit_requests SET status = 'EXPIRED', updated_at = now() WHERE id = $1", [id]); + throw new ApiError(400, 'QR 유효기간이 지났습니다.'); + } + await dbQuery( + 'INSERT INTO access_events (created_at, updated_at, visit_request_id, direction, gate_id, operator_id, event_at) VALUES (now(), now(), $1, $2, $3, $4, now())', + [id, direction, req.body?.gateId ? String(req.body.gateId) : null, actor ? Number(actor.id) : null], + ); + if (direction === 'OUT') { + await dbQuery( + "UPDATE visitors SET name='***', contact='***', email='***', vehicle_no='***', updated_at = now() WHERE id = (SELECT visitor_id FROM visit_requests WHERE id = $1)", + [id], + ); + } + ok(res, { + visitRequestId: id, + visitorName: visit.visitor_name, + direction, + eventAt: new Date().toISOString(), + gateOpened: true, + message: direction === 'IN' ? '입장 처리되었습니다.' : '퇴장 처리되었습니다.', + }); + } + + router.post('/access/check-in', asyncRoute(async (req, res) => accessAction(req, res, 'IN'))); + router.post('/access/check-out', asyncRoute(async (req, res) => accessAction(req, res, 'OUT'))); + router.post('/public/passes/:token/check-in', asyncRoute(async (req, res) => accessAction(req, res, 'IN'))); + router.post('/public/passes/:token/check-out', asyncRoute(async (req, res) => accessAction(req, res, 'OUT'))); + + router.get('/access/search', asyncRoute(async (req, res) => { + await requireCurrentUser(dbQuery, req); + const q = `%${String(req.query.q ?? '').trim()}%`; + ok(res, await listVisitRows(dbQuery, "WHERE vr.status = 'APPROVED' AND (v.name ILIKE $1 OR v.company ILIKE $1 OR vr.zone_name ILIKE $1)", [q])); + })); + + router.get('/access/inside', asyncRoute(async (req, res) => { + await requireCurrentUser(dbQuery, req); + const result = await dbQuery( + ` + SELECT DISTINCT ON (vr.id) + vr.id AS visit_request_id, v.name AS visitor_name, v.company, vr.zone_name, h.full_name AS host_name, + vr.visit_from, vr.visit_to, ae.event_at AS check_in_at, NULL::timestamp AS check_out_at + FROM visit_requests vr + JOIN visitors v ON v.id = vr.visitor_id + JOIN users h ON h.id = vr.host_id + JOIN access_events ae ON ae.visit_request_id = vr.id AND ae.direction = 'IN' + WHERE NOT EXISTS ( + SELECT 1 FROM access_events out_ae + WHERE out_ae.visit_request_id = vr.id AND out_ae.direction = 'OUT' AND out_ae.event_at > ae.event_at + ) + ORDER BY vr.id, ae.event_at DESC + `, + ); + ok(res, result.rows.map((row) => ({ + visitRequestId: Number(row.visit_request_id), + visitorName: row.visitor_name, + company: row.company ?? undefined, + zoneName: row.zone_name ?? undefined, + hostName: row.host_name, + checkInAt: toIso(row.check_in_at)!, + }))); + })); + + router.get('/access/today', asyncRoute(async (req, res) => { + await requireCurrentUser(dbQuery, req); + const result = await dbQuery( + ` + SELECT + vr.id AS visit_request_id, v.name AS visitor_name, v.company, vr.zone_name, h.full_name AS host_name, + vr.visit_from, vr.visit_to, + min(ae.event_at) FILTER (WHERE ae.direction = 'IN') AS check_in_at, + max(ae.event_at) FILTER (WHERE ae.direction = 'OUT') AS check_out_at, + (max(ae.event_at) FILTER (WHERE ae.direction = 'IN')) > COALESCE(max(ae.event_at) FILTER (WHERE ae.direction = 'OUT'), 'epoch') AS inside + FROM visit_requests vr + JOIN visitors v ON v.id = vr.visitor_id + JOIN users h ON h.id = vr.host_id + LEFT JOIN access_events ae ON ae.visit_request_id = vr.id AND ae.event_at::date = now()::date + WHERE vr.visit_from::date = now()::date + GROUP BY vr.id, v.id, h.id + ORDER BY vr.visit_from + `, + ); + ok(res, result.rows.map((row) => ({ + visitRequestId: Number(row.visit_request_id), + visitorName: row.visitor_name, + company: row.company ?? undefined, + zoneName: row.zone_name ?? undefined, + visitFrom: toIso(row.visit_from)!, + visitTo: toIso(row.visit_to)!, + checkInAt: toIso(row.check_in_at), + checkOutAt: toIso(row.check_out_at), + inside: row.inside, + }))); + })); + + router.get('/stats/summary', asyncRoute(async (req, res) => { + await requireCurrentUser(dbQuery, req); + const result = await dbQuery<{ today_visits: string; pending: string; approved: string; total: string }>( + ` + SELECT + count(*) FILTER (WHERE visit_from::date = now()::date) AS today_visits, + count(*) FILTER (WHERE status = 'PENDING') AS pending, + count(*) FILTER (WHERE status = 'APPROVED') AS approved, + count(*) AS total + FROM visit_requests + `, + ); + const inside = await dbQuery<{ count: string }>( + ` + SELECT count(DISTINCT ae.visit_request_id) + FROM access_events ae + WHERE ae.direction = 'IN' + AND NOT EXISTS ( + SELECT 1 FROM access_events out_ae + WHERE out_ae.visit_request_id = ae.visit_request_id AND out_ae.direction = 'OUT' AND out_ae.event_at > ae.event_at + ) + `, + ); + const row = result.rows[0]; + ok(res, { + todayVisits: Number(row.today_visits), + pending: Number(row.pending), + approved: Number(row.approved), + currentlyInside: Number(inside.rows[0].count), + total: Number(row.total), + }); + })); + + router.get('/blacklist', asyncRoute(async (req, res) => { + await requireAdmin(dbQuery, req); + const result = await dbQuery( + ` + SELECT b.*, u.full_name AS created_by_name + FROM blacklist b + LEFT JOIN users u ON u.id = b.created_by + ORDER BY b.created_at DESC + `, + ); + ok(res, result.rows.map((row) => ({ + id: Number(row.id), + name: row.name, + company: row.company ?? undefined, + contact: row.contact ?? undefined, + reason: row.reason, + active: row.active, + createdByName: row.created_by_name ?? undefined, + createdAt: toIso(row.created_at)!, + }))); + })); + + router.post('/blacklist', asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + const result = await dbQuery<{ id: string }>( + 'INSERT INTO blacklist (created_at, updated_at, name, company, contact, reason, active, created_by) VALUES (now(), now(), $1, $2, $3, $4, TRUE, $5) RETURNING id', + [required(req.body?.name, '이름'), req.body?.company || null, req.body?.contact || null, required(req.body?.reason, '사유'), Number(actor.id)], + ); + await audit(dbQuery, actor, 'BLACKLIST_ADD', 'BLACKLIST', Number(result.rows[0].id), '블랙리스트 추가'); + ok(res, (await dbQuery('SELECT * FROM blacklist WHERE id = $1', [Number(result.rows[0].id)])).rows[0]); + })); + + router.delete('/blacklist/:id', asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + await dbQuery('UPDATE blacklist SET active = FALSE, updated_at = now() WHERE id = $1', [Number(req.params.id)]); + await audit(dbQuery, actor, 'BLACKLIST_REMOVE', 'BLACKLIST', Number(req.params.id), '블랙리스트 해제'); + ok(res, '삭제되었습니다.'); + })); + + router.get('/reports/visits', asyncRoute(async (req, res) => { + await requireCurrentUser(dbQuery, req); + ok(res, await listVisitRows(dbQuery, 'WHERE vr.visit_from::date BETWEEN $1::date AND $2::date', [req.query.from, req.query.to])); + })); + + router.get('/reports/visits.xlsx', asyncRoute(async (req, res) => { + await requireCurrentUser(dbQuery, req); + const visits = await listVisitRows(dbQuery, 'WHERE vr.visit_from::date BETWEEN $1::date AND $2::date', [req.query.from, req.query.to]); + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet('visits'); + sheet.addRow(['방문자', '회사', '구역', '목적', '작업명', '상태', '출입일시', '퇴실일시']); + for (const visit of visits) { + sheet.addRow([visit.visitorName, visit.company ?? '', visit.zoneName ?? '', visit.purpose, visit.workName ?? '', visit.status, visit.visitFrom, visit.visitTo]); + } + sheet.getRow(1).font = { bold: true }; + const buffer = await workbook.xlsx.writeBuffer(); + res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet').send(Buffer.from(buffer)); + })); + + router.get('/admin/audit', asyncRoute(async (req, res) => { + await requireAdmin(dbQuery, req); + const result = await dbQuery('SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 300'); + ok(res, result.rows.map((row) => ({ + id: Number(row.id), + at: toIso(row.created_at)!, + actorId: row.actor_id ? Number(row.actor_id) : undefined, + actorUsername: row.actor_username ?? undefined, + action: row.action, + targetType: row.target_type ?? undefined, + targetId: row.target_id ? Number(row.target_id) : undefined, + detail: row.detail ?? undefined, + }))); + })); + + router.get('/admin/deliveries', asyncRoute(async (req, res) => { + await requireAdmin(dbQuery, req); + const status = req.query.status ? String(req.query.status) : null; + const result = await dbQuery( + `SELECT * FROM pass_deliveries ${status ? 'WHERE status = $1' : ''} ORDER BY created_at DESC LIMIT 300`, + status ? [status] : [], + ); + ok(res, result.rows.map((row) => ({ + id: Number(row.id), + visitRequestId: Number(row.visit_request_id), + channel: row.channel ?? undefined, + recipient: row.recipient ?? undefined, + status: row.status, + attempts: Number(row.attempts), + lastError: row.last_error ?? undefined, + createdAt: toIso(row.created_at)!, + updatedAt: toIso(row.updated_at)!, + }))); + })); + + router.post('/admin/deliveries/:id/retry', asyncRoute(async (req, res) => { + await requireAdmin(dbQuery, req); + const result = await dbQuery( + "UPDATE pass_deliveries SET attempts = attempts + 1, status = 'SENT', last_error = NULL, updated_at = now() WHERE id = $1 RETURNING *", + [Number(req.params.id)], + ); + ok(res, { + id: Number(result.rows[0].id), + visitRequestId: Number(result.rows[0].visit_request_id), + channel: result.rows[0].channel ?? undefined, + recipient: result.rows[0].recipient ?? undefined, + status: result.rows[0].status, + attempts: Number(result.rows[0].attempts), + lastError: result.rows[0].last_error ?? undefined, + createdAt: toIso(result.rows[0].created_at)!, + updatedAt: toIso(result.rows[0].updated_at)!, + }); + })); + + return router; +} + +export const businessRouter = createBusinessRouter(); diff --git a/server/routes/index.ts b/server/routes/index.ts index 4d0f637..18cf4f7 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -1,8 +1,13 @@ import { Router } from 'express'; +import { adminRouter, configRouter } from './admin.js'; import { authRouter } from './auth.js'; +import { businessRouter } from './business.js'; import { healthRouter } from './health.js'; export const apiRouter = Router(); apiRouter.use(healthRouter); apiRouter.use('/auth', authRouter); +apiRouter.use(configRouter); +apiRouter.use('/admin', adminRouter); +apiRouter.use(businessRouter); diff --git a/server/routes/security.ts b/server/routes/security.ts new file mode 100644 index 0000000..8e4bae8 --- /dev/null +++ b/server/routes/security.ts @@ -0,0 +1,119 @@ +import type { Request } from 'express'; +import { ApiError } from '../http/errors.js'; +import { query } from '../db/pool.js'; + +export interface UserRow { + id: string; + username: string; + password_hash: string; + full_name: string; + email: string | null; + department: string | null; + team_id?: string | null; + team_code?: string | null; + team_name?: string | null; + must_change_password: boolean; + enabled: boolean; + locked: boolean; + roles: string[]; +} + +export type QueryFn = typeof query; + +export async function findUserByUsername(dbQuery: QueryFn, username: string): Promise { + const result = await dbQuery( + ` + SELECT + u.id, + u.username, + u.password_hash, + u.full_name, + u.email, + u.department, + u.team_id, + t.code AS team_code, + t.name AS team_name, + u.must_change_password, + u.enabled, + u.locked, + COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles + FROM users u + LEFT JOIN teams t ON t.id = u.team_id + LEFT JOIN user_roles ur ON ur.user_id = u.id + WHERE u.username = $1 + GROUP BY u.id, t.id + `, + [username], + ); + return result.rows[0]; +} + +export async function findUserById(dbQuery: QueryFn, id: number): Promise { + const result = await dbQuery( + ` + SELECT + u.id, + u.username, + u.password_hash, + u.full_name, + u.email, + u.department, + u.team_id, + t.code AS team_code, + t.name AS team_name, + u.must_change_password, + u.enabled, + u.locked, + COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles + FROM users u + LEFT JOIN teams t ON t.id = u.team_id + LEFT JOIN user_roles ur ON ur.user_id = u.id + WHERE u.id = $1 + GROUP BY u.id, t.id + `, + [id], + ); + return result.rows[0]; +} + +export async function requireCurrentUser(dbQuery: QueryFn, req: Request): Promise { + const userId = req.session.userId; + if (!userId) { + throw new ApiError(401, '로그인이 필요합니다.'); + } + + const user = await findUserById(dbQuery, userId); + if (!user || !user.enabled || user.locked) { + req.session.userId = undefined; + throw new ApiError(401, '로그인이 필요합니다.'); + } + + return user; +} + +export async function requireAdmin(dbQuery: QueryFn, req: Request): Promise { + const user = await requireCurrentUser(dbQuery, req); + if (!user.roles.includes('ADMIN')) { + throw new ApiError(403, '접근 권한이 없습니다.'); + } + return user; +} + +export function parseRole(value: string): string { + const role = value.trim().toUpperCase() === 'USER' ? 'HOST' : value.trim().toUpperCase(); + if (!['ADMIN', 'SECURITY', 'HOST'].includes(role)) { + throw new ApiError(400, `알 수 없는 권한입니다: ${value}`); + } + return role; +} + +export function parseRoles(values: unknown): string[] { + if (!Array.isArray(values)) { + throw new ApiError(400, '권한은 배열로 입력해야 합니다.'); + } + const roles = [...new Set(values.map((value) => parseRole(String(value))))]; + if (roles.length === 0) { + throw new ApiError(400, '권한은 최소 1개 이상 필요합니다.'); + } + return roles; +}