Add AI DEV Node deployment foundation

This commit is contained in:
unknown
2026-07-13 10:33:21 +09:00
parent 6abcee3727
commit b90bfe35ca
23 changed files with 3978 additions and 0 deletions

13
.dockerignore Normal file
View 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
View File

@@ -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
View 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
View 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"]

View File

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

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

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

View 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

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
console.log('S3 SKIPPED: ACS does not currently use S3/MinIO storage.');

28
server/config/env.ts Normal file
View 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
View 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
View 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;
}

View 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
View 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
View 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
View 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
View 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
View 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"]
}