Add AI DEV Node deployment foundation
This commit is contained in:
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
backend
|
||||||
|
dist
|
||||||
|
frontend/dist
|
||||||
|
frontend/node_modules
|
||||||
|
infra
|
||||||
|
node_modules
|
||||||
|
*.log
|
||||||
|
*.bak
|
||||||
|
.env
|
||||||
|
.project-env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
docs
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,6 +6,8 @@ backend/target/
|
|||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/.vite/
|
frontend/.vite/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# IDE / OS
|
# IDE / OS
|
||||||
@@ -19,6 +21,8 @@ Thumbs.db
|
|||||||
|
|
||||||
# Local env
|
# Local env
|
||||||
infra/.env
|
infra/.env
|
||||||
|
.env
|
||||||
|
.project-env
|
||||||
|
|
||||||
# Editor/EDR leftover temp files (see docs/issues-and-guidelines.md §2-3)
|
# Editor/EDR leftover temp files (see docs/issues-and-guidelines.md §2-3)
|
||||||
*.tmp.*
|
*.tmp.*
|
||||||
|
|||||||
32
CLAUDE.md
Normal file
32
CLAUDE.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# ACS Project Rules
|
||||||
|
|
||||||
|
## Deployment Target
|
||||||
|
|
||||||
|
ACS must follow the AI DEV deployment guide in `docs/AIdev.md`.
|
||||||
|
|
||||||
|
- Deploy as a Node.js application.
|
||||||
|
- Use port `3000`.
|
||||||
|
- Use the `DATABASE_URL` supplied by `.project-env` / AI DEV.
|
||||||
|
- Do not depend on a self-managed PostgreSQL container for AI DEV deployment.
|
||||||
|
- Keep `.project-env` and `.env` out of git.
|
||||||
|
- Build and deploy through the root `Dockerfile`.
|
||||||
|
|
||||||
|
## Current Migration Direction
|
||||||
|
|
||||||
|
- The legacy Spring Boot backend remains only as a behavior reference until Node parity is complete.
|
||||||
|
- The React frontend should be preserved where possible.
|
||||||
|
- Keep the existing `/api` contract and response envelope stable:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 200, "message": "OK", "data": {} }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required Checks
|
||||||
|
|
||||||
|
- `npm run typecheck`
|
||||||
|
- `npm run build`
|
||||||
|
- `npm run db:check` in the AI DEV project folder where `.project-env` is loaded
|
||||||
|
- `curl 127.0.0.1:3000/healthz`
|
||||||
|
- `curl 127.0.0.1:3000/db`
|
||||||
|
|
||||||
|
`npm run minio:check` and `/s3` intentionally report skipped because ACS currently does not use S3/MinIO storage.
|
||||||
33
Dockerfile
Normal file
33
Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
FROM node:22-bookworm-slim AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY frontend/package*.json ./frontend/
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
RUN npm --prefix frontend ci
|
||||||
|
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY server ./server
|
||||||
|
COPY migrations ./migrations
|
||||||
|
COPY frontend ./frontend
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS runtime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY --from=build /app/frontend/dist ./frontend/dist
|
||||||
|
COPY migrations ./migrations
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "start:deploy"]
|
||||||
@@ -322,3 +322,64 @@
|
|||||||
- `ACS_COOKIE_SECURE`
|
- `ACS_COOKIE_SECURE`
|
||||||
- `ACS_SMS_PROVIDER`
|
- `ACS_SMS_PROVIDER`
|
||||||
- SMTP 또는 사내 메시지 API 설정
|
- SMTP 또는 사내 메시지 API 설정
|
||||||
|
|
||||||
|
## 2026-07-13
|
||||||
|
|
||||||
|
### Node.js 전환 착수
|
||||||
|
- 사용자가 `C:\ai-dev\workspace\acs - 복사본`에 기존 소스 백업이 있으므로, 원본 `C:\ai-dev\workspace\acs`를 AI DEV 표준 배포 구조로 전환하기로 결정.
|
||||||
|
- 목표:
|
||||||
|
- Spring Boot 백엔드 배포 방식에서 Node.js 앱 배포 방식으로 전환.
|
||||||
|
- DB는 자체 PostgreSQL 컨테이너가 아니라 AI DEV `.project-env`의 `DATABASE_URL` 사용.
|
||||||
|
- 기존 React 프론트와 `/api` 계약은 최대한 유지.
|
||||||
|
- 추가 문서:
|
||||||
|
- `docs/node-migration-plan.md`
|
||||||
|
- 추가된 Node 골격:
|
||||||
|
- 루트 `package.json`, `tsconfig.json`
|
||||||
|
- `server/index.ts`
|
||||||
|
- `server/config/env.ts`
|
||||||
|
- `server/db/pool.ts`
|
||||||
|
- `server/db/migrate.ts`
|
||||||
|
- `server/http/apiResponse.ts`
|
||||||
|
- `server/http/errors.ts`
|
||||||
|
- `server/routes/health.ts`
|
||||||
|
- `server/routes/index.ts`
|
||||||
|
- Flyway SQL을 Node migration 구조로 1차 이관:
|
||||||
|
- `migrations/001_init.sql`
|
||||||
|
- `migrations/002_audit_log.sql`
|
||||||
|
- `migrations/003_pass_delivery.sql`
|
||||||
|
- `migrations/004_visit_request_contact_fields.sql`
|
||||||
|
- 검증:
|
||||||
|
- `npm install` 완료.
|
||||||
|
- `npm run typecheck` 성공.
|
||||||
|
- `npm audit --omit=dev` 결과 운영 의존성 취약점 0건.
|
||||||
|
- `npm run build` 성공. 단, 로컬 PC 권한 정책상 프론트 `esbuild`는 승인 권한으로 실행 필요.
|
||||||
|
- `node dist/server/index.js` 기동 후 `GET /api/health` 응답 확인.
|
||||||
|
- 정적 React build(`/`) 응답 200 확인.
|
||||||
|
- 다음 작업:
|
||||||
|
- Auth/session/role middleware 구현.
|
||||||
|
- PostgreSQL session store 연결.
|
||||||
|
- 사용자 seed 전략 확정.
|
||||||
|
- `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`, `/api/auth/change-password`부터 Spring 기능 parity 구현.
|
||||||
|
|
||||||
|
### AIdev.md 배포 가이드 준수 보완
|
||||||
|
- `docs/AIdev.md` 기준 ACS 배포 필수 항목을 재점검.
|
||||||
|
- 보완 사항:
|
||||||
|
- 루트 `Dockerfile` 추가. AI DEV Coolify/Kubero Dockerfile build pack 기준으로 빌드.
|
||||||
|
- 루트 `.dockerignore` 추가.
|
||||||
|
- Node engine 기준을 `>=22`로 조정.
|
||||||
|
- `/healthz` 추가: `{"ok": true}` 응답.
|
||||||
|
- `/db` 추가: `DATABASE_URL`로 PostgreSQL `SELECT now()` 점검.
|
||||||
|
- `/s3` 추가: ACS는 S3/MinIO 미사용이므로 skip 응답.
|
||||||
|
- `npm run db:check` 추가: `.project-env`/`.env` 로드 후 DB 점검.
|
||||||
|
- `npm run minio:check` 추가: ACS S3 미사용 skip 출력.
|
||||||
|
- `npm run start:deploy` 추가: migration 적용 후 Node 서버 기동.
|
||||||
|
- `CLAUDE.md` 추가: AI DEV 배포 제약과 ACS 전환 규칙 명시.
|
||||||
|
- 검증:
|
||||||
|
- `npm run typecheck` 성공.
|
||||||
|
- `npm audit --omit=dev` 운영 의존성 취약점 0건.
|
||||||
|
- `npm run build` 성공. 이 PC에서는 esbuild 실행 정책 때문에 승인 권한 필요.
|
||||||
|
- `GET /healthz` 정상.
|
||||||
|
- `GET /api/health` 정상.
|
||||||
|
- `GET /s3` skip 응답 정상.
|
||||||
|
- `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 배포 환경에서 재검증 필요.
|
||||||
|
|||||||
176
docs/node-migration-plan.md
Normal file
176
docs/node-migration-plan.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# ACS Node.js Migration Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Convert ACS from the current Spring Boot backend deployment model to an AI DEV compliant Node.js application.
|
||||||
|
|
||||||
|
The target deployment must:
|
||||||
|
|
||||||
|
- Run as a Node.js app.
|
||||||
|
- Run on port `3000`.
|
||||||
|
- Use `process.env.DATABASE_URL` supplied by `.project-env` / AI DEV.
|
||||||
|
- Avoid self-managed PostgreSQL containers in the deployment path.
|
||||||
|
- Build through the root `Dockerfile`.
|
||||||
|
- Preserve the existing React frontend where possible.
|
||||||
|
- Preserve the current `/api` contract and response envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 200, "message": "OK", "data": {} }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Target Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
Node.js app
|
||||||
|
├─ /api/* Express API
|
||||||
|
├─ /assets, /index.html Static React build from frontend/dist
|
||||||
|
├─ PostgreSQL process.env.DATABASE_URL
|
||||||
|
└─ migrations SQL files adapted from existing Flyway migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended stack:
|
||||||
|
|
||||||
|
- Runtime: Node.js + TypeScript
|
||||||
|
- HTTP: Express
|
||||||
|
- Database: `pg`
|
||||||
|
- Session: `express-session` with PostgreSQL-backed store
|
||||||
|
- Password hashing: `bcrypt`
|
||||||
|
- QR generation: `qrcode`
|
||||||
|
- Excel import/export: `multer` + `exceljs`
|
||||||
|
- Frontend: existing React/Vite app
|
||||||
|
|
||||||
|
## Migration Principles
|
||||||
|
|
||||||
|
1. Keep the frontend API surface stable.
|
||||||
|
2. Reuse the current PostgreSQL schema as much as possible.
|
||||||
|
3. Convert by feature slice, not by framework layer.
|
||||||
|
4. Keep Spring Boot code available as the behavior reference until parity is verified.
|
||||||
|
5. Make AI DEV deployment simple: `npm install`, `npm run build`, `npm start`.
|
||||||
|
|
||||||
|
## Feature Migration Order
|
||||||
|
|
||||||
|
### Phase 1. Node Foundation
|
||||||
|
|
||||||
|
- Add root Node package and TypeScript config.
|
||||||
|
- Add `server/` source tree.
|
||||||
|
- Add env loader for local `.project-env` compatibility.
|
||||||
|
- Add PostgreSQL connection pool using `DATABASE_URL`.
|
||||||
|
- Add migration runner using SQL files in `migrations/`.
|
||||||
|
- Add health check endpoint: `GET /api/health`.
|
||||||
|
- Add AI DEV health check endpoint: `GET /healthz`.
|
||||||
|
- Add AI DEV DB check endpoint: `GET /db`.
|
||||||
|
- Add S3 status endpoint: `GET /s3` with an explicit skip response because ACS does not use S3/MinIO.
|
||||||
|
- Serve `frontend/dist` for non-API routes.
|
||||||
|
|
||||||
|
### Phase 2. Auth and Common Infrastructure
|
||||||
|
|
||||||
|
- Implement API response helper.
|
||||||
|
- Implement error handler.
|
||||||
|
- Implement session middleware.
|
||||||
|
- Implement role guard middleware.
|
||||||
|
- Implement:
|
||||||
|
- `POST /api/auth/login`
|
||||||
|
- `POST /api/auth/logout`
|
||||||
|
- `GET /api/auth/me`
|
||||||
|
- `POST /api/auth/change-password`
|
||||||
|
|
||||||
|
### Phase 3. Read-First Business APIs
|
||||||
|
|
||||||
|
- `GET /api/zones`
|
||||||
|
- `GET /api/visit-requests`
|
||||||
|
- `GET /api/visit-requests/pending`
|
||||||
|
- `GET /api/visit-requests/:id`
|
||||||
|
- `GET /api/stats/summary`
|
||||||
|
|
||||||
|
### Phase 4. Visit Request and Approval Workflow
|
||||||
|
|
||||||
|
- `POST /api/visit-requests`
|
||||||
|
- `POST /api/visit-requests/:id/cancel`
|
||||||
|
- `POST /api/approvals/:id/approve`
|
||||||
|
- `POST /api/approvals/:id/reject`
|
||||||
|
- Generate `qr_token` on approval.
|
||||||
|
- Insert approval and audit log records.
|
||||||
|
- Preserve "notification failure must not rollback approval" behavior.
|
||||||
|
|
||||||
|
### Phase 5. Pass, QR, and Access Control
|
||||||
|
|
||||||
|
- `GET /api/passes/:id`
|
||||||
|
- `GET /api/passes/:id/qr.png`
|
||||||
|
- `GET /api/public/passes/:token`
|
||||||
|
- `GET /api/public/passes/:token/qr.png`
|
||||||
|
- `POST /api/access/check-in`
|
||||||
|
- `POST /api/access/check-out`
|
||||||
|
- `GET /api/access/inside`
|
||||||
|
- `GET /api/access/today`
|
||||||
|
- Public kiosk check-in/out endpoints.
|
||||||
|
|
||||||
|
### Phase 6. Admin Features
|
||||||
|
|
||||||
|
- Blacklist CRUD.
|
||||||
|
- Audit log list.
|
||||||
|
- Delivery outbox list and retry.
|
||||||
|
- Excel upload for visit requests.
|
||||||
|
- XLSX visit report download.
|
||||||
|
|
||||||
|
### Phase 7. Deployment Cleanup
|
||||||
|
|
||||||
|
- Update README and AI DEV run instructions.
|
||||||
|
- Mark Spring Boot backend and Docker Compose deployment as legacy.
|
||||||
|
- Keep or remove legacy files after user confirmation.
|
||||||
|
- Verify deployment in AI DEV with real `.project-env`.
|
||||||
|
|
||||||
|
## API Compatibility Rules
|
||||||
|
|
||||||
|
- Keep `/api` prefix.
|
||||||
|
- Keep frontend DTO field names in camelCase.
|
||||||
|
- Keep HTTP status behavior close to the Spring implementation:
|
||||||
|
- 400 validation error
|
||||||
|
- 401 unauthenticated
|
||||||
|
- 403 forbidden
|
||||||
|
- 404 missing resource
|
||||||
|
- 409 business conflict
|
||||||
|
- State-changing APIs must require an authenticated session unless they are public token endpoints.
|
||||||
|
- Public pass endpoints must not require login.
|
||||||
|
|
||||||
|
## Database Migration Strategy
|
||||||
|
|
||||||
|
Existing files:
|
||||||
|
|
||||||
|
- `V1__init.sql`
|
||||||
|
- `V2__audit_log.sql`
|
||||||
|
- `V3__pass_delivery.sql`
|
||||||
|
- `V4__visit_request_contact_fields.sql`
|
||||||
|
|
||||||
|
Node target:
|
||||||
|
|
||||||
|
- Copy SQL into `migrations/001_init.sql` etc.
|
||||||
|
- Create a `schema_migrations` table.
|
||||||
|
- Apply migrations in filename order.
|
||||||
|
- Do not create or manage a PostgreSQL container.
|
||||||
|
- Use only `DATABASE_URL`.
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
- `npm run typecheck`
|
||||||
|
- `npm run build`
|
||||||
|
- `npm run db:check` in AI DEV/Coder with `.project-env` loaded
|
||||||
|
- `npm run minio:check` returns a documented skip because ACS does not use S3
|
||||||
|
- `npm start`
|
||||||
|
- `GET /healthz`
|
||||||
|
- `GET /db`
|
||||||
|
- `GET /api/health`
|
||||||
|
- Login with seeded admin user.
|
||||||
|
- Create visit request.
|
||||||
|
- Approve request and confirm QR token.
|
||||||
|
- Open public pass page.
|
||||||
|
- Check in and check out.
|
||||||
|
- Confirm inside/today access views.
|
||||||
|
- Confirm blacklist blocks check-in.
|
||||||
|
- Download report XLSX.
|
||||||
|
|
||||||
|
## Open Decisions
|
||||||
|
|
||||||
|
- Exact AI DEV Node version.
|
||||||
|
- Whether AI DEV automatically runs `npm run build` or only `npm start`.
|
||||||
|
- Whether `.project-env` exists in the repository root or must be sourced by the shell before startup.
|
||||||
|
- Whether the production app should seed initial users automatically or require an explicit seed command.
|
||||||
106
migrations/001_init.sql
Normal file
106
migrations/001_init.sql
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
-- Ported from backend/src/main/resources/db/migration/V1__init.sql
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
username VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
full_name VARCHAR(80) NOT NULL,
|
||||||
|
email VARCHAR(120),
|
||||||
|
department VARCHAR(80),
|
||||||
|
must_change_password BOOLEAN NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL,
|
||||||
|
locked BOOLEAN NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_roles (
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users (id),
|
||||||
|
role VARCHAR(20) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_user_roles_user ON user_roles (user_id);
|
||||||
|
|
||||||
|
CREATE TABLE zones (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
code VARCHAR(30) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(80) NOT NULL,
|
||||||
|
description VARCHAR(255),
|
||||||
|
security_level INTEGER NOT NULL,
|
||||||
|
active BOOLEAN NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE visitors (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
name VARCHAR(80) NOT NULL,
|
||||||
|
company VARCHAR(120),
|
||||||
|
contact VARCHAR(40),
|
||||||
|
email VARCHAR(120),
|
||||||
|
vehicle_no VARCHAR(20)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_visitor_name ON visitors (name);
|
||||||
|
CREATE INDEX idx_visitor_contact ON visitors (contact);
|
||||||
|
|
||||||
|
CREATE TABLE visit_requests (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
visitor_id BIGINT NOT NULL REFERENCES visitors (id),
|
||||||
|
host_id BIGINT NOT NULL REFERENCES users (id),
|
||||||
|
zone_name VARCHAR(80),
|
||||||
|
purpose VARCHAR(255) NOT NULL,
|
||||||
|
visit_from TIMESTAMP NOT NULL,
|
||||||
|
visit_to TIMESTAMP NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
qr_token VARCHAR(64)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_vr_status ON visit_requests (status);
|
||||||
|
CREATE INDEX idx_vr_visit_from ON visit_requests (visit_from);
|
||||||
|
CREATE INDEX idx_vr_qr_token ON visit_requests (qr_token);
|
||||||
|
|
||||||
|
CREATE TABLE approvals (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
visit_request_id BIGINT NOT NULL REFERENCES visit_requests (id),
|
||||||
|
approver_id BIGINT NOT NULL REFERENCES users (id),
|
||||||
|
decision VARCHAR(20) NOT NULL,
|
||||||
|
comment VARCHAR(500),
|
||||||
|
decided_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE access_events (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
visit_request_id BIGINT NOT NULL REFERENCES visit_requests (id),
|
||||||
|
direction VARCHAR(8) NOT NULL,
|
||||||
|
gate_id VARCHAR(40),
|
||||||
|
operator_id BIGINT REFERENCES users (id),
|
||||||
|
event_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_ae_visit_request ON access_events (visit_request_id);
|
||||||
|
CREATE INDEX idx_ae_event_at ON access_events (event_at);
|
||||||
|
CREATE INDEX idx_ae_direction ON access_events (direction);
|
||||||
|
|
||||||
|
CREATE TABLE blacklist (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
name VARCHAR(80) NOT NULL,
|
||||||
|
company VARCHAR(120),
|
||||||
|
contact VARCHAR(40),
|
||||||
|
reason VARCHAR(255) NOT NULL,
|
||||||
|
active BOOLEAN NOT NULL,
|
||||||
|
created_by BIGINT REFERENCES users (id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_bl_name ON blacklist (name);
|
||||||
|
CREATE INDEX idx_bl_contact ON blacklist (contact);
|
||||||
|
|
||||||
|
INSERT INTO zones (created_at, updated_at, code, name, description, security_level, active) VALUES
|
||||||
|
(now(), now(), 'LOBBY', '로비', NULL, 1, TRUE),
|
||||||
|
(now(), now(), 'OFFICE', '사무공간', NULL, 2, TRUE),
|
||||||
|
(now(), now(), 'SERVER_ROOM', '전산실', NULL, 3, TRUE);
|
||||||
15
migrations/002_audit_log.sql
Normal file
15
migrations/002_audit_log.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Ported from backend/src/main/resources/db/migration/V2__audit_log.sql
|
||||||
|
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
actor_id BIGINT,
|
||||||
|
actor_username VARCHAR(50),
|
||||||
|
action VARCHAR(30) NOT NULL,
|
||||||
|
target_type VARCHAR(30),
|
||||||
|
target_id BIGINT,
|
||||||
|
detail VARCHAR(500)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_audit_created_at ON audit_logs (created_at);
|
||||||
|
CREATE INDEX idx_audit_action ON audit_logs (action);
|
||||||
15
migrations/003_pass_delivery.sql
Normal file
15
migrations/003_pass_delivery.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Ported from backend/src/main/resources/db/migration/V3__pass_delivery.sql
|
||||||
|
|
||||||
|
CREATE TABLE pass_deliveries (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
visit_request_id BIGINT NOT NULL,
|
||||||
|
channel VARCHAR(20),
|
||||||
|
recipient VARCHAR(120),
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL,
|
||||||
|
last_error VARCHAR(500)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_pd_status ON pass_deliveries (status);
|
||||||
|
CREATE INDEX idx_pd_visit_request ON pass_deliveries (visit_request_id);
|
||||||
12
migrations/004_visit_request_contact_fields.sql
Normal file
12
migrations/004_visit_request_contact_fields.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Ported from backend/src/main/resources/db/migration/V4__visit_request_contact_fields.sql
|
||||||
|
|
||||||
|
ALTER TABLE visit_requests ADD COLUMN work_name VARCHAR(255);
|
||||||
|
ALTER TABLE visit_requests ADD COLUMN control_name VARCHAR(80);
|
||||||
|
ALTER TABLE visit_requests ADD COLUMN control_team VARCHAR(80);
|
||||||
|
ALTER TABLE visit_requests ADD COLUMN control_contact VARCHAR(60);
|
||||||
|
ALTER TABLE visit_requests ADD COLUMN watcher1_name VARCHAR(80);
|
||||||
|
ALTER TABLE visit_requests ADD COLUMN watcher1_team VARCHAR(80);
|
||||||
|
ALTER TABLE visit_requests ADD COLUMN watcher1_contact VARCHAR(60);
|
||||||
|
ALTER TABLE visit_requests ADD COLUMN watcher2_name VARCHAR(80);
|
||||||
|
ALTER TABLE visit_requests ADD COLUMN watcher2_team VARCHAR(80);
|
||||||
|
ALTER TABLE visit_requests ADD COLUMN watcher2_contact VARCHAR(60);
|
||||||
3188
package-lock.json
generated
Normal file
3188
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
Normal file
47
package.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "acs-node",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "npm run build:frontend && npm run build:server",
|
||||||
|
"build:frontend": "npm --prefix frontend run build",
|
||||||
|
"build:server": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"db:check": "node scripts/check-db.mjs",
|
||||||
|
"minio:check": "node scripts/check-minio.mjs",
|
||||||
|
"dev": "tsx watch server/index.ts",
|
||||||
|
"migrate": "tsx server/db/migrate.ts",
|
||||||
|
"start": "node dist/server/index.js",
|
||||||
|
"start:deploy": "node dist/server/db/migrate.js && node dist/server/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"connect-pg-simple": "^10.0.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"uuid": "^11.0.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/connect-pg-simple": "^7.0.3",
|
||||||
|
"@types/cookie-parser": "^1.4.8",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/express-session": "^1.18.1",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
scripts/check-db.mjs
Normal file
30
scripts/check-db.mjs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
const projectEnvPath = path.resolve(process.cwd(), '.project-env');
|
||||||
|
const dotEnvPath = path.resolve(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (existsSync(projectEnvPath)) {
|
||||||
|
dotenv.config({ path: projectEnvPath });
|
||||||
|
} else if (existsSync(dotEnvPath)) {
|
||||||
|
dotenv.config({ path: dotEnvPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
console.error('DB FAIL: DATABASE_URL is not set. Run inside the AI DEV project folder with .project-env loaded.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query('SELECT now() AS now');
|
||||||
|
console.log(`DB OK: ${result.rows[0]?.now}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`DB FAIL: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
1
scripts/check-minio.mjs
Normal file
1
scripts/check-minio.mjs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
console.log('S3 SKIPPED: ACS does not currently use S3/MinIO storage.');
|
||||||
28
server/config/env.ts
Normal file
28
server/config/env.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
const projectEnvPath = path.resolve(process.cwd(), '.project-env');
|
||||||
|
const dotEnvPath = path.resolve(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (existsSync(projectEnvPath)) {
|
||||||
|
dotenv.config({ path: projectEnvPath });
|
||||||
|
} else if (existsSync(dotEnvPath)) {
|
||||||
|
dotenv.config({ path: dotEnvPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||||
|
port: Number(process.env.PORT ?? 3000),
|
||||||
|
databaseUrl: process.env.DATABASE_URL,
|
||||||
|
sessionSecret: process.env.SESSION_SECRET ?? 'dev-only-change-me',
|
||||||
|
publicBaseUrl: process.env.ACS_PUBLIC_BASE_URL ?? 'http://localhost:3000',
|
||||||
|
smsProvider: process.env.ACS_SMS_PROVIDER ?? 'dev',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function requireDatabaseUrl(): string {
|
||||||
|
if (!env.databaseUrl) {
|
||||||
|
throw new Error('DATABASE_URL is required. In AI DEV it should be supplied by .project-env.');
|
||||||
|
}
|
||||||
|
return env.databaseUrl;
|
||||||
|
}
|
||||||
56
server/db/migrate.ts
Normal file
56
server/db/migrate.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { closePool, query } from './pool.js';
|
||||||
|
|
||||||
|
const migrationsDir = path.resolve(process.cwd(), 'migrations');
|
||||||
|
|
||||||
|
async function ensureMigrationTable(): Promise<void> {
|
||||||
|
await query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
filename VARCHAR(255) PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appliedMigrations(): Promise<Set<string>> {
|
||||||
|
const result = await query<{ filename: string }>('SELECT filename FROM schema_migrations');
|
||||||
|
return new Set(result.rows.map((row) => row.filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runMigrations(): Promise<void> {
|
||||||
|
await ensureMigrationTable();
|
||||||
|
const applied = await appliedMigrations();
|
||||||
|
const files = (await readdir(migrationsDir))
|
||||||
|
.filter((file) => file.endsWith('.sql'))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (applied.has(file)) continue;
|
||||||
|
|
||||||
|
const sql = await readFile(path.join(migrationsDir, file), 'utf8');
|
||||||
|
await query('BEGIN');
|
||||||
|
try {
|
||||||
|
await query(sql);
|
||||||
|
await query('INSERT INTO schema_migrations (filename) VALUES ($1)', [file]);
|
||||||
|
await query('COMMIT');
|
||||||
|
console.log(`Applied migration ${file}`);
|
||||||
|
} catch (error) {
|
||||||
|
await query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
runMigrations()
|
||||||
|
.then(async () => {
|
||||||
|
await closePool();
|
||||||
|
})
|
||||||
|
.catch(async (error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
await closePool();
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
23
server/db/pool.ts
Normal file
23
server/db/pool.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import pg from 'pg';
|
||||||
|
import { requireDatabaseUrl } from '../config/env.js';
|
||||||
|
|
||||||
|
let pool: pg.Pool | undefined;
|
||||||
|
|
||||||
|
export function getPool(): pg.Pool {
|
||||||
|
pool ??= new pg.Pool({
|
||||||
|
connectionString: requireDatabaseUrl(),
|
||||||
|
});
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function query<T extends pg.QueryResultRow = pg.QueryResultRow>(
|
||||||
|
text: string,
|
||||||
|
params?: unknown[],
|
||||||
|
): Promise<pg.QueryResult<T>> {
|
||||||
|
return getPool().query<T>(text, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closePool(): Promise<void> {
|
||||||
|
await pool?.end();
|
||||||
|
pool = undefined;
|
||||||
|
}
|
||||||
15
server/http/apiResponse.ts
Normal file
15
server/http/apiResponse.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ok<T>(res: Response, data: T, message = 'OK'): void {
|
||||||
|
res.status(200).json({ code: 200, message, data } satisfies ApiResponse<T>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function created<T>(res: Response, data: T, message = 'CREATED'): void {
|
||||||
|
res.status(201).json({ code: 201, message, data } satisfies ApiResponse<T>);
|
||||||
|
}
|
||||||
33
server/http/errors.ts
Normal file
33
server/http/errors.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorHandler(
|
||||||
|
error: unknown,
|
||||||
|
_req: Request,
|
||||||
|
res: Response,
|
||||||
|
_next: NextFunction,
|
||||||
|
): void {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
res.status(error.status).json({
|
||||||
|
code: error.status,
|
||||||
|
message: error.message,
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({
|
||||||
|
code: 500,
|
||||||
|
message: 'Internal server error',
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
28
server/index.ts
Normal file
28
server/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import express from 'express';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import { env } from './config/env.js';
|
||||||
|
import { apiRouter } from './routes/index.js';
|
||||||
|
import { platformStatusRouter } from './routes/health.js';
|
||||||
|
import { errorHandler } from './http/errors.js';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const frontendDist = path.resolve(process.cwd(), 'frontend', 'dist');
|
||||||
|
|
||||||
|
app.disable('x-powered-by');
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
app.use(platformStatusRouter);
|
||||||
|
app.use('/api', apiRouter);
|
||||||
|
|
||||||
|
app.use(express.static(frontendDist));
|
||||||
|
app.get('*', (_req, res) => {
|
||||||
|
res.sendFile(path.join(frontendDist, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
app.listen(env.port, () => {
|
||||||
|
console.log(`ACS Node app listening on port ${env.port}`);
|
||||||
|
});
|
||||||
38
server/routes/health.ts
Normal file
38
server/routes/health.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { ok } from '../http/apiResponse.js';
|
||||||
|
import { query } from '../db/pool.js';
|
||||||
|
|
||||||
|
export const healthRouter = Router();
|
||||||
|
|
||||||
|
export const platformStatusRouter = Router();
|
||||||
|
|
||||||
|
healthRouter.get('/health', (_req, res) => {
|
||||||
|
ok(res, {
|
||||||
|
status: 'UP',
|
||||||
|
service: 'acs-node',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
platformStatusRouter.get('/healthz', (_req, res) => {
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
platformStatusRouter.get('/db', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await query<{ now: Date }>('SELECT now() AS now');
|
||||||
|
res.json({ ok: true, now: result.rows[0]?.now });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
platformStatusRouter.get('/s3', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
skipped: true,
|
||||||
|
reason: 'ACS does not currently use S3/MinIO storage.',
|
||||||
|
});
|
||||||
|
});
|
||||||
6
server/routes/index.ts
Normal file
6
server/routes/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { healthRouter } from './health.js';
|
||||||
|
|
||||||
|
export const apiRouter = Router();
|
||||||
|
|
||||||
|
apiRouter.use(healthRouter);
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["server/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "frontend", "backend", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user