Compare commits
57 Commits
4cb7801fe8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fb26bb7b25 | |||
| c55f464f3c | |||
| aa799edf2c | |||
| cc47c6f5f1 | |||
|
|
15145260f9 | ||
| 360ab1ac12 | |||
|
|
e58e164e54 | ||
| bbfbc5b910 | |||
| 670228b76e | |||
|
|
754e494e7f | ||
|
|
5dec188744 | ||
|
|
02e509986b | ||
|
|
da568bb85f | ||
|
|
3448594bc5 | ||
|
|
d2a7a3e478 | ||
|
|
72c2573648 | ||
|
|
c98baa94fc | ||
|
|
c414899a4f | ||
|
|
1ef1f8148f | ||
|
|
772239e708 | ||
|
|
7f41c35cb8 | ||
|
|
d3844aec71 | ||
|
|
33022c1840 | ||
|
|
a70ad11035 | ||
|
|
10150583cc | ||
|
|
4d7e69247d | ||
| eeb7808e29 | |||
|
|
03a8a3b864 | ||
|
|
6fea909902 | ||
|
|
dd48a26196 | ||
|
|
cb4146fa35 | ||
|
|
60880fb12e | ||
| d3339d75bf | |||
|
|
a1e9600ad5 | ||
|
|
a3d2e24861 | ||
|
|
2215633fb9 | ||
| bf3d9fc1d0 | |||
|
|
5cac3bdabf | ||
|
|
905afb56f5 | ||
| 5be0f5407b | |||
|
|
8b2dff8d1d | ||
| 83fc120885 | |||
|
|
60ea49ac4f | ||
|
|
1c710015de | ||
|
|
8d89e5f49a | ||
|
|
cac84677fb | ||
|
|
709d18199c | ||
|
|
b7260357af | ||
|
|
b686855d82 | ||
|
|
a337732d47 | ||
|
|
7dd5ce8015 | ||
|
|
aca3667a9d | ||
|
|
d8c604de1d | ||
|
|
5ce2b68155 | ||
|
|
82fc98e9df | ||
|
|
83a8cddc82 | ||
|
|
db7030883f |
0
.github/copilot-instructions.md
vendored
Executable file → Normal file
0
.github/copilot-instructions.md
vendored
Executable file → Normal file
20
.gitignore
vendored
Executable file → Normal file
20
.gitignore
vendored
Executable file → Normal file
@@ -1,7 +1,16 @@
|
|||||||
# ยกเว้นโฟลเดอร์
|
# ยกเว้นโฟลเดอร์
|
||||||
.devcontainer/
|
.devcontainer/
|
||||||
|
.qsync/
|
||||||
@Recently-Snapshot/
|
@Recently-Snapshot/
|
||||||
Documents/
|
Documents/
|
||||||
|
mariadb/data/
|
||||||
|
n8n*/
|
||||||
|
n8n-postgres/
|
||||||
|
npm/
|
||||||
|
phpmyadmin/
|
||||||
|
pgadmin/
|
||||||
|
.tmp.driveupload
|
||||||
|
.qsync
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# IDE/Editor settings
|
# IDE/Editor settings
|
||||||
# =====================================================
|
# =====================================================
|
||||||
@@ -14,9 +23,9 @@ Documents/
|
|||||||
/frontend/node_modules/
|
/frontend/node_modules/
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
# lockfiles
|
# lockfiles
|
||||||
/backend/package-lock.json
|
# /backend/package-lock.json
|
||||||
/frontend/package-lock.json
|
# /frontend/package-lock.json
|
||||||
**/package-lock.json
|
# **/package-lock.json
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# Next.js build output
|
# Next.js build output
|
||||||
# =====================================================
|
# =====================================================
|
||||||
@@ -84,4 +93,9 @@ docker-compose.override.*.yml
|
|||||||
/backend/.cache/
|
/backend/.cache/
|
||||||
/frontend/.cache/
|
/frontend/.cache/
|
||||||
.tmp/
|
.tmp/
|
||||||
|
.tmp*.*/
|
||||||
.cache/
|
.cache/
|
||||||
|
# Ignore Nginx Proxy Manager data
|
||||||
|
/npm/
|
||||||
|
|
||||||
|
/n8n-postgres/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
[/dms]
|
[/dms]
|
||||||
max_log = 361676
|
max_log = 510381
|
||||||
number = 5
|
number = 3
|
||||||
finish = 1
|
finish = 1
|
||||||
|
|||||||
21225
.qsync/meta/qmeta0
21225
.qsync/meta/qmeta0
File diff suppressed because it is too large
Load Diff
22597
.qsync/meta/qmeta1
22597
.qsync/meta/qmeta1
File diff suppressed because it is too large
Load Diff
19358
.qsync/meta/qmeta2
19358
.qsync/meta/qmeta2
File diff suppressed because it is too large
Load Diff
10997
.qsync/meta/qmeta3
10997
.qsync/meta/qmeta3
File diff suppressed because it is too large
Load Diff
1955
.qsync/meta/qmeta4
1955
.qsync/meta/qmeta4
File diff suppressed because it is too large
Load Diff
506
Architech.md
Executable file
506
Architech.md
Executable file
@@ -0,0 +1,506 @@
|
|||||||
|
# DMS Architecture Deep Dive (Backend + Frontend)
|
||||||
|
|
||||||
|
**Project:** Document Management System (DMS) — LCBP3
|
||||||
|
**Platform:** QNAP TS‑473A (Container Station)
|
||||||
|
**Last updated:** 2025‑10‑07 (UTC+7)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0) TL;DR (Executive Summary)
|
||||||
|
|
||||||
|
* Reverse proxy (Nginx/NPM) เผยแพร่ Frontend (Next.js) และ Backend (Node.js/Express) ผ่าน HTTPS (HSTS)
|
||||||
|
* Backend เชื่อม MariaDB 10.11 (ข้อมูลหลัก DMS) และแยก n8n + Postgres 16 สำหรับ workflow
|
||||||
|
* RBAC/ABAC ถูกบังคับใช้งานใน middleware + มีชุด SQL (tables → triggers → procedures → views → seed)
|
||||||
|
* ไฟล์จริง (PDF/DWG) เก็บนอก webroot ที่ **/share/dms‑data** พร้อมมาตรฐานการตั้งชื่อ+โฟลเดอร์
|
||||||
|
* Dev/Prod แยกชัดเจนผ่าน Docker multi‑stage + docker‑compose + โฟลเดอร์ persist logs/config/certs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Runtime Topology & Trust Boundaries
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet Clients (Browser)
|
||||||
|
│ HTTPS 443 (HSTS) [QNAP mgmt = 8443]
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Reverse Proxy Layer │
|
||||||
|
│ ├─ Nginx (Alpine) or Nginx Proxy Manager (NPM) │
|
||||||
|
│ ├─ TLS (LE cert; SAN multi‑subdomain) │
|
||||||
|
│ └─ Routes: │
|
||||||
|
│ • /, /_next/* → Frontend (Next.js :3000) │
|
||||||
|
│ • /api/* → Backend (Express :3001) │
|
||||||
|
│ • /pma/* → phpMyAdmin │
|
||||||
|
│ • /n8n/* → n8n (Workflows) │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
│ └──────────┐
|
||||||
|
▼ │
|
||||||
|
Frontend (Next.js) │
|
||||||
|
│ Cookie-based Auth (HttpOnly) │
|
||||||
|
▼ ▼
|
||||||
|
Backend (Node/Express ESM) ─────────► MariaDB 10.11
|
||||||
|
│ │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
Project data (.pdf/.dwg) @ /share/dms-data
|
||||||
|
|
||||||
|
n8n (workflows) ──► Postgres 16 (separate DB for automations)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trust Boundaries**
|
||||||
|
|
||||||
|
* Public zone: Internet ↔ Reverse proxy
|
||||||
|
* App zone: Reverse proxy ↔ FE/BE containers (internal Docker network)
|
||||||
|
* Data zone: Backend ↔ Databases (MariaDB, Postgres) + `/share/dms-data`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Frontend Architecture (Next.js / React)
|
||||||
|
|
||||||
|
### 2.1 Stack & Key libs
|
||||||
|
|
||||||
|
* **Next.js (App Router)**, **React**, ESM
|
||||||
|
* **Tailwind CSS**, **PostCSS**, **shadcn/ui** (components.json)
|
||||||
|
* Fetch API (credentials include) → Cookie Auth (HttpOnly)
|
||||||
|
|
||||||
|
### 2.2 Directory Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
/frontend/
|
||||||
|
├─ app/
|
||||||
|
│ ├─ login/
|
||||||
|
│ ├─ dashboard/
|
||||||
|
│ ├─ users/
|
||||||
|
│ ├─ correspondences/
|
||||||
|
│ ├─ health/
|
||||||
|
│ └─ layout.tsx / page.tsx (ตาม App Router)
|
||||||
|
├─ public/
|
||||||
|
├─ Dockerfile (multi-stage: dev/prod)
|
||||||
|
├─ package.json
|
||||||
|
├─ next.config.js
|
||||||
|
└─ ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Routing & Layouts
|
||||||
|
|
||||||
|
* **Public**: `/login`, `/health`
|
||||||
|
* **Protected**: `/dashboard`, `/users`, `/correspondences`, ... (client-side guard)
|
||||||
|
* เก็บ **middleware.ts (ของเดิม)** เพื่อหลีกเลี่ยง regression; ใช้ client‑guard + server action อย่างระมัดระวัง
|
||||||
|
|
||||||
|
### 2.4 Auth Flow (Cookie-based)
|
||||||
|
|
||||||
|
1. ผู้ใช้ submit form `/login` → `POST /api/auth/login` (Backend)
|
||||||
|
2. Backend set **HttpOnly** cookie (JWT) + `SameSite=Lax/Strict`, `Secure`
|
||||||
|
3. หน้า protected เรียก `GET /api/auth/me` เพื่อตรวจสอบสถานะ
|
||||||
|
4. หาก 401 → redirect → `/login`
|
||||||
|
|
||||||
|
> **CORS/Fetch**: เปิด `credentials: 'include'` ทุกครั้ง, ตั้ง `NEXT_PUBLIC_API_BASE` เป็น origin ของ backend ผ่าน proxy (เช่น `https://lcbp3.np-dms.work`)
|
||||||
|
|
||||||
|
### 2.5 UI/UX
|
||||||
|
|
||||||
|
* Sea‑blue palette, sidebar พับได้, card‑based KPI
|
||||||
|
* ตารางข้อมูลเตรียมรองรับ **server‑side DataTables**
|
||||||
|
* shadcn/ui: Button, Card, Badge, Tabs, Dropdown, Tooltip, Switch, etc.
|
||||||
|
|
||||||
|
### 2.6 Config & ENV
|
||||||
|
|
||||||
|
* `NEXT_PUBLIC_API_BASE` (ex: `https://lcbp3.np-dms.work`)
|
||||||
|
* Build output แยก dev/prod; ระวัง EACCES บน QNAP → ใช้ user `node` + ปรับสิทธิ์โวลุ่ม `.next/*`
|
||||||
|
|
||||||
|
### 2.7 Error Handling & Observability (FE)
|
||||||
|
|
||||||
|
* Global error boundary (app router) + toast/alert patterns
|
||||||
|
* Network layer: แยก handler สำหรับ 401/403/500 + retry/backoff ที่จำเป็น
|
||||||
|
* Metrics (optional): web‑vitals, UX timing (เก็บฝั่ง n8n หรือ simple logging)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Backend Architecture (Node.js ESM / Express)
|
||||||
|
|
||||||
|
### 3.1 Stack & Structure
|
||||||
|
|
||||||
|
* Node 20.x, **ESM** modules, **Express**
|
||||||
|
* `mysql2/promise`, `jsonwebtoken`, `cookie-parser`, `cors`, `helmet`, `winston/morgan`
|
||||||
|
|
||||||
|
```tree
|
||||||
|
/backend/
|
||||||
|
├─ src/
|
||||||
|
│ ├─ index.js # bootstrap server, CORS, cookies, health
|
||||||
|
│ ├─ routes/
|
||||||
|
│ │ ├─ auth.js # /api/auth/* (login, me, logout)
|
||||||
|
│ │ ├─ users.js # /api/users/*
|
||||||
|
│ │ ├─ correspondences.js # /api/correspondences/*
|
||||||
|
│ │ ├─ drawings.js # /api/drawings/*
|
||||||
|
│ │ ├─ rfas.js # /api/rfas/*
|
||||||
|
│ │ └─ transmittals.js # /api/transmittals/*
|
||||||
|
│ ├─ middleware/
|
||||||
|
│ │ ├─ authGuard.js # verify JWT from cookie
|
||||||
|
│ │ ├─ requirePermission.js# RBAC/ABAC enforcement
|
||||||
|
│ │ ├─ errorHandler.js
|
||||||
|
│ │ └─ requestLogger.js
|
||||||
|
│ ├─ db/
|
||||||
|
│ │ ├─ pool.js # createPool, sane defaults
|
||||||
|
│ │ └─ models/ # query builders (User, Drawing, ...)
|
||||||
|
│ ├─ utils/
|
||||||
|
│ │ ├─ hash.js (bcrypt/argon2)
|
||||||
|
│ │ ├─ jwt.js
|
||||||
|
│ │ ├─ pagination.js
|
||||||
|
│ │ └─ responses.js
|
||||||
|
│ └─ config/
|
||||||
|
│ └─ index.js # env, constants
|
||||||
|
├─ Dockerfile
|
||||||
|
└─ package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Request Lifecycle
|
||||||
|
|
||||||
|
1. `helmet` + `cors` (allow specific origin; credentials true)
|
||||||
|
2. `cookie-parser`, `json limit` (e.g., 2MB)
|
||||||
|
3. `requestLogger` → trace + response time
|
||||||
|
4. Route handler → `authGuard` (protected) → `requirePermission` (per‑route) → Controller
|
||||||
|
5. Error bubbles → `errorHandler` (JSON shape, status map)
|
||||||
|
|
||||||
|
### 3.3 Auth & RBAC/ABAC
|
||||||
|
|
||||||
|
* **JWT** ใน HttpOnly cookie; Claims: `sub` (user_id), `roles`, `exp`
|
||||||
|
* **authGuard**: ตรวจ token → แนบ `req.user`
|
||||||
|
* **requirePermission**: เช็ค permission ตามเส้นทาง/วิธี; แผนขยาย ABAC (เช่น project scope, owner, doc state)
|
||||||
|
* Roles/Permissions ถูก seed ใน SQL; มี **view เมทริกซ์** เพื่อ debug (เช่น `v_role_permission_matrix`)
|
||||||
|
|
||||||
|
**ตัวอย่าง pseudo** `requirePermission(permission)`
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const requirePermission = (perm) => async (req, res, next) => {
|
||||||
|
if (!req.user) return res.status(401).json({ error: 'Unauthenticated' });
|
||||||
|
const ok = await checkPermission(req.user.user_id, perm, req.context);
|
||||||
|
if (!ok) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Database Access & Pooling
|
||||||
|
|
||||||
|
* `createPool({ connectionLimit: 10~25, queueLimit: 0, waitForConnections: true })`
|
||||||
|
* ใช้ parameterized queries เสมอ; ปรับ `sql_mode` ที่จำเป็นใน `my.cnf`
|
||||||
|
|
||||||
|
### 3.5 File Storage & Secure Download
|
||||||
|
|
||||||
|
* Root: **/share/dms‑data**
|
||||||
|
* โครงโฟลเดอร์: `{module}/{yyyy}/{mm}/{entityId}/` + ชื่อไฟล์ตามมาตรฐาน (เช่น `DRW-<code>-REV-<rev>.pdf`)
|
||||||
|
* Endpoint download: ตรวจสิทธิ์ (RBAC/ABAC) → `res.sendFile()`/stream; ป้องกัน path traversal
|
||||||
|
* MIME allowlist + size limit + virus scan (optional; ภายหลัง)
|
||||||
|
|
||||||
|
### 3.6 Health & Readiness
|
||||||
|
|
||||||
|
* `GET /api/health` → `{ ok: true }`
|
||||||
|
* (optional) `/api/ready` ตรวจ DB ping + disk space (dms‑data)
|
||||||
|
|
||||||
|
### 3.7 Config & ENV (BE)
|
||||||
|
|
||||||
|
* `DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME`
|
||||||
|
* `JWT_SECRET, COOKIE_NAME, COOKIE_SAMESITE, COOKIE_SECURE`
|
||||||
|
* `CORS_ORIGIN, LOG_LEVEL, APP_BASE_URL`
|
||||||
|
* `FILE_ROOT=/share/dms-data`
|
||||||
|
|
||||||
|
### 3.8 Logging
|
||||||
|
|
||||||
|
* Access log (morgan) + App log (winston) → `/share/Container/dms/logs/backend/`
|
||||||
|
* รูปแบบ JSON (timestamp, level, msg, reqId) + daily rotation (logrotate/container‑side)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Database (MariaDB 10.11)
|
||||||
|
|
||||||
|
### 4.1 Schema Overview (ย่อ)
|
||||||
|
|
||||||
|
* **RBAC core**: `users`, `roles`, `permissions`, `user_roles`, `role_permissions`
|
||||||
|
* **Domain**: `drawings`, `contracts`, `correspondences`, `rfas`, `transmittals`, `organizations`, `projects`, ...
|
||||||
|
* **Audit**: `audit_logs` (แผนขยาย), `deleted_at` (soft delete, แผนงาน)
|
||||||
|
|
||||||
|
```
|
||||||
|
[users]──<user_roles>──[roles]──<role_permissions>──[permissions]
|
||||||
|
│
|
||||||
|
└── activities/audit_logs (future expansion)
|
||||||
|
|
||||||
|
[drawings]──<mapping>──[contracts]
|
||||||
|
[rfas]──<links>──[drawings]
|
||||||
|
[correspondences] (internal/external flag)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Init SQL Pipeline
|
||||||
|
|
||||||
|
1. `01_*_deploy_table_rbac.sql` — สร้างตารางหลักทั้งหมด + RBAC
|
||||||
|
2. `02_*_triggers.sql` — บังคับ data rules, auto‑audit fields
|
||||||
|
3. `03_*_procedures_handlers.sql` — upsert/bulk handlers (เช่น `sp_bulk_import_contract_dwg`)
|
||||||
|
4. `04_*_views.sql` — รายงาน/เมทริกซ์สิทธิ์ (`v_role_permission_matrix`, etc.)
|
||||||
|
5. `05_*_seed_data.sql` — ค่าพื้นฐาน domain (project, categories, statuses)
|
||||||
|
6. `06_*_seed_users.sql` — บัญชีเริ่มต้น (superadmin, editors, viewers)
|
||||||
|
7. `07_*_seed_contract_dwg.sql` — ข้อมูลตัวอย่างแบบสัญญา
|
||||||
|
|
||||||
|
### 4.3 Indexing & Performance
|
||||||
|
|
||||||
|
* Composite indexes ตามคอลัมน์ filter/sort (เช่น `(project_id, updated_at DESC)`)
|
||||||
|
* Full‑text index (optional) สำหรับ advanced search
|
||||||
|
* Query plan review (EXPLAIN) + เพิ่ม covering index ตามรายงาน
|
||||||
|
|
||||||
|
### 4.4 MySQL/MariaDB Config (my.cnf — แนวทาง)
|
||||||
|
|
||||||
|
```
|
||||||
|
[mysqld]
|
||||||
|
innodb_buffer_pool_size = 4G # ปรับตาม RAM/QNAP
|
||||||
|
innodb_log_file_size = 512M
|
||||||
|
innodb_flush_log_at_trx_commit = 1
|
||||||
|
max_connections = 200
|
||||||
|
sql_mode = STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION
|
||||||
|
character-set-server = utf8mb4
|
||||||
|
collation-server = utf8mb4_unicode_ci
|
||||||
|
```
|
||||||
|
|
||||||
|
> ปรับค่าให้เหมาะกับ workload จริง + เฝ้าดู IO/CPU ของ QNAP
|
||||||
|
|
||||||
|
### 4.5 Backup/Restore
|
||||||
|
|
||||||
|
* Logical backup: `mysqldump --routines --triggers --single-transaction`
|
||||||
|
* Physical (snapshot QNAP) + schedule ผ่าน n8n/cron
|
||||||
|
* เก็บสำเนา off‑NAS (encrypted)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Reverse Proxy & TLS
|
||||||
|
|
||||||
|
### 5.1 Nginx (Alpine) — ตัวอย่าง server block
|
||||||
|
|
||||||
|
> **สำคัญ:** บนสภาพแวดล้อมนี้ ให้ใช้คนละบรรทัด:
|
||||||
|
> `listen 443 ssl;`
|
||||||
|
> `http2 on;`
|
||||||
|
> หลีกเลี่ยง `listen 443 ssl http2;`
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name lcbp3.np-dms.work;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name lcbp3.np-dms.work;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; preload" always;
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Next.js static
|
||||||
|
location /_next/ {
|
||||||
|
proxy_pass http://frontend:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
location /api/ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_pass http://backend:3001;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# phpMyAdmin (sub-path)
|
||||||
|
location /pma/ {
|
||||||
|
proxy_pass http://phpmyadmin:80/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# n8n
|
||||||
|
location /n8n/ {
|
||||||
|
proxy_pass http://n8n:5678/;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Nginx Proxy Manager (NPM) — Tips
|
||||||
|
|
||||||
|
* ระวังอย่าใส่ `proxy_http_version` ซ้ำซ้อน (duplicate directive) ใน Advanced
|
||||||
|
* ถ้าต้องแก้ไฟล์ด้านใน NPM → ระวังไฟล์ใน `/data/nginx/proxy_host/*.conf`
|
||||||
|
* จัดการ certificate / SAN หลาย sub‑domain ใน UI แต่ mainten ดีเรื่อง symlink/renew
|
||||||
|
|
||||||
|
### 5.3 TLS & Certificates
|
||||||
|
|
||||||
|
* Let’s Encrypt (HTTP‑01 webroot/standalone) + HSTS
|
||||||
|
* QNAP mgmt เปลี่ยนเป็น 8443 → พอร์ต 443 public ว่างสำหรับ Nginx/NPM
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Docker Compose Topology
|
||||||
|
|
||||||
|
### 6.1 Services (สรุป)
|
||||||
|
|
||||||
|
* `frontend` (Next.js) :3000
|
||||||
|
* `backend` (Express) :3001
|
||||||
|
* `mariadb` (10.11) :3306 (internal)
|
||||||
|
* `phpmyadmin` :80 (internal)
|
||||||
|
* `nginx` or `npm` :80/443 (published)
|
||||||
|
* `n8n` :5678 (internal)
|
||||||
|
* `postgres_n8n` (16-alpine)
|
||||||
|
* `pgadmin4`
|
||||||
|
|
||||||
|
### 6.2 Volumes & Paths
|
||||||
|
|
||||||
|
```
|
||||||
|
/share/Container/dms/
|
||||||
|
├─ mariadb/data
|
||||||
|
├─ mariadb/init/*.sql
|
||||||
|
├─ backend/ (code)
|
||||||
|
├─ frontend/ (code)
|
||||||
|
├─ phpmyadmin/{sessions,tmp,config.user.inc.php}
|
||||||
|
├─ nginx/{nginx.conf,dms.conf,certs/}
|
||||||
|
├─ n8n, n8n-postgres, n8n-cache
|
||||||
|
└─ logs/{backend,frontend,nginx,pgadmin,phpmyadmin,postgres_n8n}
|
||||||
|
/share/dms-data (pdf/dwg storage)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Healthchecks (suggested)
|
||||||
|
|
||||||
|
* **backend**: curl `http://localhost:3001/api/health`
|
||||||
|
* **frontend**: curl `/health` (simple JSON)
|
||||||
|
* **mariadb**: `mysqladmin ping` with credentials
|
||||||
|
* **nginx**: `nginx -t` at startup
|
||||||
|
|
||||||
|
### 6.4 Security Hardening
|
||||||
|
|
||||||
|
* รัน container ด้วย user non‑root (`user: node` สำหรับ FE/BE)
|
||||||
|
* จำกัด capabilities; read‑only FS (ยกเว้นโวลุ่มจำเป็น)
|
||||||
|
* เฉพาะ backend เมานต์ `/share/dms-data`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Observability, Ops, and Troubleshooting
|
||||||
|
|
||||||
|
### 7.1 Logs
|
||||||
|
|
||||||
|
* Frontend → `/logs/frontend/*`
|
||||||
|
* Backend → `/logs/backend/*` (app/access/error)
|
||||||
|
* Nginx/NPM → `/logs/nginx/*`
|
||||||
|
* MariaDB → default datadir log + slow query (เปิดใน my.cnf หากต้องการ)
|
||||||
|
|
||||||
|
### 7.2 Common Issues & Playbooks
|
||||||
|
|
||||||
|
* **401 Unauthenticated**: ตรวจ `authGuard` → JWT cookie มี/หมดอายุ → เวลา server/FE sync → CORS `credentials: true`
|
||||||
|
* **EACCES Next.js**: สิทธิ์ `.next/*` + run as `node`, โวลุ่ม map ถูก user:group
|
||||||
|
* **NPM duplicate directive**: ลบซ้ำ `proxy_http_version` ใน Advanced / ตรวจ `proxy_host/*.conf`
|
||||||
|
* **LE cert path/symlink**: ตรวจ `/etc/letsencrypt/live/npm-*` symlink ชี้ถูก
|
||||||
|
* **DB field not found**: ตรวจ schema vs code (migration/init SQL) → sync ให้ตรง
|
||||||
|
|
||||||
|
### 7.3 Performance Guides
|
||||||
|
|
||||||
|
* **Backend**: keep‑alive, gzip/deflate at proxy, pool 10–25, paginate, avoid N+1
|
||||||
|
* **Frontend**: prefetch critical routes, cache static, image optimization
|
||||||
|
* **DB**: เพิ่ม index จุด filter, analyze query (EXPLAIN), ปรับ buffer pool
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Security & Compliance
|
||||||
|
|
||||||
|
* **HTTPS only** + HSTS (preload)
|
||||||
|
* **CORS**: allow list เฉพาะ FE origin; `Access-Control-Allow-Credentials: true`
|
||||||
|
* **Cookie**: HttpOnly, Secure, SameSite=Lax/Strict
|
||||||
|
* **Input Validation**: celebrate/zod (optional) + sanitize
|
||||||
|
* **Rate limiting**: per IP/route (optional)
|
||||||
|
* **AuditLog**: วางแผนเพิ่ม ครอบคลุม CRUD + mapping (actor, action, entity, before/after)
|
||||||
|
* **Backups**: DB + `/share/dms-data` + config (encrypted off‑NAS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Backlog → Architecture Mapping
|
||||||
|
|
||||||
|
1. **RBAC Enforcement ครบ** → เติม `requirePermission` ทุก route + test matrix ผ่าน view
|
||||||
|
2. **AuditLog ครบ CRUD/Mapping** → trigger + table `audit_logs` + BE hook
|
||||||
|
3. **Upload/Download จริงของ Drawing Revisions** → BE endpoints + virus scan (optional)
|
||||||
|
4. **Dashboard KPI** → BE summary endpoints + FE cards/charts
|
||||||
|
5. **Server‑side DataTables** → paging/sort/filter + indexesรองรับ
|
||||||
|
6. **รายงาน Export CSV/Excel/PDF** → BE export endpoints + FE buttons
|
||||||
|
7. **Soft delete** (`deleted_at`) → BE filter default scope + restore endpoint
|
||||||
|
8. **Validation เข้ม** → celebrate/zod schema + consistent error shape
|
||||||
|
9. **Indexing/Perf** → slow query log + EXPLAIN review
|
||||||
|
10. **Job/Cron Deadline Alerts** → n8n schedule + SMTP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) Port & ENV Matrix (Quick Ref)
|
||||||
|
|
||||||
|
| Component | Ports | Key ENV |
|
||||||
|
| --------- | --------------- | ------------------------------------------------ |
|
||||||
|
| Nginx/NPM | 80/443 (public) | SSL paths, HSTS |
|
||||||
|
| Frontend | 3000 (internal) | `NEXT_PUBLIC_API_BASE` |
|
||||||
|
| Backend | 3001 (internal) | `DB_*`, `JWT_SECRET`, `CORS_ORIGIN`, `FILE_ROOT` |
|
||||||
|
| MariaDB | 3306 (internal) | `MY_CNF`, credentials |
|
||||||
|
| n8n | 5678 (internal) | `N8N_*`, webhook URL under `/n8n/` |
|
||||||
|
| Postgres | 5432 (internal) | n8n DB |
|
||||||
|
|
||||||
|
**QNAP mgmt**: 8443 (already moved)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11) Sample Snippets
|
||||||
|
|
||||||
|
### 11.1 Backend CORS (credentials)
|
||||||
|
|
||||||
|
```js
|
||||||
|
app.use(cors({
|
||||||
|
origin: ['https://lcbp3.np-dms.work'],
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 Secure Download (guarded)
|
||||||
|
|
||||||
|
```js
|
||||||
|
router.get('/files/:module/:id/:filename', authGuard, requirePermission('file.read'), async (req, res) => {
|
||||||
|
const { module, id, filename } = req.params;
|
||||||
|
// 1) ABAC: verify user can access this module/entity
|
||||||
|
const ok = await canReadFile(req.user.user_id, module, id);
|
||||||
|
if (!ok) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
|
||||||
|
const abs = path.join(FILE_ROOT, module, id, filename);
|
||||||
|
if (!abs.startsWith(FILE_ROOT)) return res.status(400).json({ error: 'Bad path' });
|
||||||
|
return res.sendFile(abs);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 Healthcheck
|
||||||
|
|
||||||
|
```js
|
||||||
|
router.get('/health', (req, res) => res.json({ ok: true }));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12) Deployment Workflow (Suggested)
|
||||||
|
|
||||||
|
1. Git (Gitea) branch strategy `feature/*` → PR → main
|
||||||
|
2. Build images (dev/prod) via Dockerfile multi‑stage; pin Node/MariaDB versions
|
||||||
|
3. `docker compose up -d --build` จาก `/share/Container/dms`
|
||||||
|
4. Validate: `/health`, `/api/health`, login roundtrip
|
||||||
|
5. Monitor logs + baseline perf; run SQL smoke tests (views/triggers/procs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13) Appendix
|
||||||
|
|
||||||
|
* **Naming conventions**: snake_case DB, camelCase JS
|
||||||
|
* **Timezones**: store UTC in DB; display in app TZ (+07:00)
|
||||||
|
* **Character set**: UTF‑8 (`utf8mb4_unicode_ci`)
|
||||||
|
* **Large file policy**: size limit (e.g., 50–200MB), allowlist extensions
|
||||||
|
* **Retention**: archive strategy for old revisions (optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> หากต้องการ เวอร์ชัน **README.md พร้อมโค้ดตัวอย่าง compose/nginx** จัดรูปแบบให้นำไปวางใน repo ได้ทันที แจ้งได้เลยว่าจะให้แตกไฟล์เป็น `/docs/Architecture.md` + `/nginx/dms.conf` + `/docker-compose.yml` template หรือรูปแบบอื่นที่สะดวกต่อการใช้งานของทีม
|
||||||
772
README.md
Executable file → Normal file
772
README.md
Executable file → Normal file
@@ -1,29 +1,695 @@
|
|||||||
# บทบาท: คุณคือ Programmer และ Document Engineer ที่เชี่ยวชาญ
|
# 📝 0. Project Title: Document Management System (DMS) Web Application for Laem Chabang Port Development Project, Phase 3
|
||||||
1. การพัฒนาเว็บแอป (Web Application Development)
|
|
||||||
2. Configuration of Container Station on QNAP
|
## 0. Project
|
||||||
3. Database: mariadb:10.11
|
|
||||||
4. Database management: phpmyadmin:5-apache
|
### 📌 0.1 Project Overview / Description
|
||||||
5. Backend: node:.js (ESM)
|
|
||||||
6. Frontend: next.js, react
|
- ระบบ Document Management System (DMS) เป็นเว็บแอปพลิเคชันที่ออกแบบมาเพื่อจัดการเอกสารภายในโครงการอย่างมีประสิทธิภาพ
|
||||||
7. Workflow automation: n8n:
|
- โดยมีฟังก์ชันหลักในการอัปโหลด จัดเก็บ ค้นหา แชร์ และควบคุมสิทธิ์การเข้าถึงเอกสาร
|
||||||
8. Workflow database: postgres:16-alpine
|
- ระบบนี้จะช่วยลดการใช้เอกสารกระดาษ เพิ่มความปลอดภัยในการจัดเก็บข้อมูล
|
||||||
9. Workflow database management: pgadmin4
|
- เพิ่มความสะดวกในการทำงานร่วมกันระหว่างองกรณ์
|
||||||
10. Reverse proxy: nginx:1.27-alpine
|
|
||||||
11. linux on QNAP
|
### 🎯 0.2 Objectives
|
||||||
12. การจัดการฐานข้อมูล (Database Management)
|
|
||||||
13. การวิเคราะห์ฐานข้อมูล (Database Analysis)
|
- พัฒนาระบบที่สามารถจัดการเอกสารได้อย่างเป็นระบบ
|
||||||
14. การจัดการฐานข้อมูลเชิงสัมพันธ์ (Relational Databases)
|
- ลดความซ้ำซ้อนในการจัดเก็บเอกสาร
|
||||||
15. ภาษา SQL
|
- เพิ่มความปลอดภัยในการเข้าถึงและจัดการเอกสาร
|
||||||
16. RBAC
|
- รองรับการทำงานร่วมกันแบบออนไลน์
|
||||||
|
|
||||||
|
### 📦 0.3 Scope of Work
|
||||||
|
|
||||||
|
ระบบจะครอบคลุมฟีเจอร์หลักดังนี้:
|
||||||
|
|
||||||
|
- การลงทะเบียนและเข้าสู่ระบบ ของผู้ใช้งาน
|
||||||
|
- การอัปโหลดและจัดเก็บเอกสารในรูปแบบต่าง ๆ (PDF, DOCX, XLSX ฯลฯ)
|
||||||
|
- การจัดหมวดหมู่และแท็กเอกสาร
|
||||||
|
- การค้นหาเอกสารด้วยคำสำคัญหรือฟิลเตอร์
|
||||||
|
- การกำหนดสิทธิ์การเข้าถึงเอกสาร (เช่น อ่านอย่างเดียว, แก้ไข, ลบ)
|
||||||
|
- การบันทึกประวัติการใช้งานเอกสาร (Audit Trail)
|
||||||
|
- การมอบหมายงานให้กับผู้เกี่ยวข้อง และแจ้งเตือนเมื่อมีการมอบหมายงาน
|
||||||
|
- การแจ้งเตือนเมื่อถึงกำหนดวันที่ต้องส่งเอกสารต่อให้ ผู้เกี่ยวข้องอื่นๆ
|
||||||
|
- การแจ้งเตือนเมื่อมีการเปลี่ยนแปลงเอกสาร
|
||||||
|
|
||||||
|
### 👥 0.4 Target Users
|
||||||
|
|
||||||
|
- พนักงานภายใน ขององค์กร
|
||||||
|
- พนักงานควบคุมเอกสาร (Document Control)/ ผู้ดูแลระบบขององค์กร (admin)
|
||||||
|
- ผู้จัดการฝ่ายเอกสาร ขององค์กร
|
||||||
|
- ผู้จัดการโครงการ ขององค์กร
|
||||||
|
- คณะกรรมการ ของโครงการ
|
||||||
|
- ผู้ดูแลระบบ IT ของโครงการ (superadmin)
|
||||||
|
|
||||||
|
### 📈 0.5 Expected Outcomes
|
||||||
|
|
||||||
|
- ลดเวลาในการค้นหาเอกสารลงอย่างน้อย 50%
|
||||||
|
- ลดเวลาในการจัดทำรายงานเอกสาร ประจำวัน, ประจำสัปดาห์, ประจำเดือน, ประจำปี และ รายงานเอกสารทั้งโครงการ
|
||||||
|
- ลดการใช้เอกสารกระดาษในองค์กร
|
||||||
|
- เพิ่มความปลอดภัยในการจัดเก็บข้อมูล
|
||||||
|
- รองรับการทำงานแบบ Remote Work
|
||||||
|
|
||||||
|
### 📘 0.6 Requirements Use Cases
|
||||||
|
|
||||||
|
#### 📘 Use Case: Upload Document
|
||||||
|
|
||||||
|
Actor: พนักงานควบคุมเอกสาร (Document Control)
|
||||||
|
Description: พนักงานควบคุมเอกสารสามารถอัปโหลดเอกสารเข้าสู่ระบบเพื่อจัดเก็บและใช้งานในภายหลัง
|
||||||
|
Preconditions: พนักงานควบคุมเอกสารต้องเข้าสู่ระบบก่อน
|
||||||
|
Main Flow:
|
||||||
|
|
||||||
|
พนักงานควบคุมเอกสารเลือกเมนู “อัปโหลดเอกสาร”
|
||||||
|
เลือกไฟล์จากเครื่องคอมพิวเตอร์
|
||||||
|
กรอกข้อมูลประกอบ เช่น ชื่อเอกสาร หมวดหมู่ แท็ก
|
||||||
|
กดปุ่ม “อัปโหลด”
|
||||||
|
ระบบบันทึกเอกสารและแสดงผลการอัปโหลดสำเร็จ
|
||||||
|
|
||||||
|
Postconditions: เอกสารถูกจัดเก็บในระบบและสามารถค้นหาได้
|
||||||
|
|
||||||
|
#### 📘 Use Case: Assign Users to Document
|
||||||
|
|
||||||
|
Actor: พนักงานควบคุมเอกสาร (Document Control)
|
||||||
|
Description: พนักงานควบคุมเอกสารสามารถ มอบหมายงานให้กับ Users
|
||||||
|
Preconditions: พนักงานควบคุมเอกสารต้องเข้าสู่ระบบก่อน, เอกสารต้องอัปโหลดเรียบร้อยแล้ว
|
||||||
|
Main Flow:
|
||||||
|
|
||||||
|
พนักงานควบคุมเอกสารเลือกเมนู “มอบหมายงาน”
|
||||||
|
เลือกเอกสารในระบบ
|
||||||
|
เลือก Users กำหนดวันสิ้นสุดงาน
|
||||||
|
กดปุ่ม “มอบหมายงาน”
|
||||||
|
ระบบบันทึกเอกสารและแสดงผลการมอบหมายงานสำเร็จ
|
||||||
|
|
||||||
|
Postconditions: งานที่มอยหมาย จัดเก็บในระบบและสามารถค้นหาได้
|
||||||
|
|
||||||
|
#### 📘 Use Case: Search Document
|
||||||
|
|
||||||
|
Actor: ผู้ใช้งานทั่วไป
|
||||||
|
Description: ผู้ใช้งานสามารถค้นหาเอกสารจากระบบด้วยคำสำคัญหรือฟิลเตอร์
|
||||||
|
Preconditions: ผู้ใช้งานต้องเข้าสู่ระบบ
|
||||||
|
Main Flow:
|
||||||
|
|
||||||
|
ผู้ใช้งานกรอกคำค้นหรือเลือกฟิลเตอร์ (หมวดหมู่, วันที่, ผู้สร้าง, ผู้ได้รับมอบหมายงาน, สถานะ, title, subject)
|
||||||
|
กดปุ่ม “ค้นหา”
|
||||||
|
ระบบแสดงรายการเอกสารที่ตรงกับเงื่อนไข
|
||||||
|
|
||||||
|
Postconditions: ผู้ใช้งานสามารถเปิดดูหรือดาวน์โหลดเอกสารที่ค้นพบได้
|
||||||
|
|
||||||
|
#### 📘 Use Case: Manage Access
|
||||||
|
|
||||||
|
Actor: ผู้ดูแลระบบโครงการ (superadmin) / ผู้ดูแลระบบขององค์กร (admin)
|
||||||
|
Description: ผู้ดูแลระบบสามารถกำหนดสิทธิ์การเข้าถึงเอกสารให้กับผู้ใช้งาน
|
||||||
|
Preconditions: ผู้ดูแลระบบต้องเข้าสู่ระบบ
|
||||||
|
Main Flow:
|
||||||
|
|
||||||
|
ผู้ดูแลระบบเลือกเอกสาร
|
||||||
|
กด “จัดการสิทธิ์”
|
||||||
|
เลือกผู้ใช้งานและกำหนดสิทธิ์ (อ่าน, แก้ไข, ลบ)
|
||||||
|
กด “บันทึก”
|
||||||
|
|
||||||
|
Postconditions: สิทธิ์การเข้าถึงเอกสารถูกปรับตามที่กำหนด
|
||||||
|
|
||||||
|
#### 📘 Use Case: View Document History
|
||||||
|
|
||||||
|
Actor: ผู้ใช้งานทั่วไป / ผู้ดูแลระบบ
|
||||||
|
Description: ผู้ใช้งานสามารถดูประวัติการใช้งานเอกสาร เช่น การแก้ไข การดาวน์โหลด
|
||||||
|
Preconditions: ผู้ใช้งานต้องมีสิทธิ์เข้าถึงเอกสาร
|
||||||
|
Main Flow:
|
||||||
|
|
||||||
|
ผู้ใช้งานเปิดเอกสาร
|
||||||
|
เลือก “ดูประวัติ”
|
||||||
|
ระบบแสดงรายการกิจกรรมที่เกี่ยวข้องกับเอกสาร
|
||||||
|
|
||||||
|
Postconditions: ผู้ใช้งานสามารถตรวจสอบการเปลี่ยนแปลงย้อนหลังได้
|
||||||
|
|
||||||
|
### 🔄 0.7 Workflow อัตโนมัติในระบบ DMS
|
||||||
|
|
||||||
|
✅ ประโยชน์ของ Workflow อัตโนมัติใน DMS
|
||||||
|
|
||||||
|
- ลดภาระงานซ้ำ ๆ ของผู้ใช้งาน
|
||||||
|
- เพิ่มความปลอดภัยและการควบคุมเอกสาร
|
||||||
|
- เพิ่มความเร็วในการดำเนินงาน
|
||||||
|
- ลดข้อผิดพลาดจากการทำงานด้วยมือ
|
||||||
|
|
||||||
|
#### 🧩 Workflow: 1. Document treat Workflow
|
||||||
|
|
||||||
|
กรณี: เมื่อมีการอัปโหลดเอกสารต้องได้รับการมอบหมายงานให้กับ พนักงานภายในองค์กรณ์
|
||||||
|
ขั้นตอนอัตโนมัติ:
|
||||||
|
|
||||||
|
1. ผู้ใช้งานอัปโหลดเอกสารและเลือก “มอบหมายงาน”
|
||||||
|
2. ระบบส่งแจ้งเตือนไปยังผู้ได้รับมอบหมายงาน
|
||||||
|
3. ผู้อนุมัติสามารถตรวจสอบและกด “ตรวจสอบแล้ว”
|
||||||
|
4. ระบบบันทึกสถานะเอกสารและ ส่งต่อ ไปยัง องกรณือื่น ตามลำดับ เมื่อได้ผลและจัดทำเอกสารตอบแล้ว จึงแจ้งผลกลับไปยังผู้ส่ง
|
||||||
|
|
||||||
|
#### 📥 Workflow: 2. Auto Tagging & Categorization
|
||||||
|
|
||||||
|
กรณี: เอกสารที่อัปโหลดมีชื่อหรือเนื้อหาที่ตรงกับหมวดหมู่ที่กำหนดไว้
|
||||||
|
ขั้นตอนอัตโนมัติ:
|
||||||
|
|
||||||
|
เมื่ออัปโหลดเอกสาร ระบบวิเคราะห์ชื่อไฟล์หรือเนื้อหา
|
||||||
|
ระบบกำหนดหมวดหมู่และแท็กให้โดยอัตโนมัติ เช่น “ใบเสนอราคา” → หมวด “การเงิน”
|
||||||
|
ผู้ใช้งานสามารถแก้ไขได้หากต้องการ
|
||||||
|
|
||||||
|
#### 🔐 Workflow: 3. Access Control Workflow
|
||||||
|
|
||||||
|
กรณี: เอกสารที่มีความลับสูงต้องจำกัดการเข้าถึง
|
||||||
|
ขั้นตอนอัตโนมัติ:
|
||||||
|
|
||||||
|
เมื่ออัปโหลดเอกสารที่มีคำว่า “ลับ” หรือ “Confidential”
|
||||||
|
ระบบกำหนดสิทธิ์เริ่มต้นให้เฉพาะผู้ใช้งานระดับผู้จัดการขึ้นไป
|
||||||
|
ระบบแจ้งเตือนผู้ดูแลระบบให้ตรวจสอบสิทธิ์เพิ่มเติม
|
||||||
|
|
||||||
|
#### 📤 Workflow: 4. Expiry & Archiving Workflow
|
||||||
|
|
||||||
|
กรณี: เอกสารที่มีอายุการใช้งาน เช่น สัญญา หรือใบอนุญาต
|
||||||
|
ขั้นตอนอัตโนมัติ:
|
||||||
|
|
||||||
|
เมื่ออัปโหลดเอกสาร ผู้ใช้งานระบุวันหมดอายุ
|
||||||
|
ระบบแจ้งเตือนก่อนหมดอายุล่วงหน้า เช่น 30 วัน
|
||||||
|
เมื่อถึงวันหมดอายุ ระบบย้ายเอกสารไปยังหมวด “Archive” โดยอัตโนมัติ
|
||||||
|
|
||||||
|
#### 📊 Workflow: 5. Audit Trail & Notification Workflow
|
||||||
|
|
||||||
|
กรณี: มีการแก้ไขหรือดาวน์โหลดเอกสารสำคัญ
|
||||||
|
ขั้นตอนอัตโนมัติ:
|
||||||
|
|
||||||
|
ทุกการกระทำกับเอกสาร (เปิด, แก้ไข, ลบ) จะถูกบันทึกใน Audit Log
|
||||||
|
หากเอกสารถูกแก้ไขโดยผู้ใช้งานที่ไม่ใช่เจ้าของ ระบบแจ้งเตือนเจ้าของเอกสารทันที
|
||||||
|
|
||||||
|
## 🛠️ 1. DMS Architecture Deep Dive (Backend + Frontend)
|
||||||
|
|
||||||
|
### 1.1 Executive Summary
|
||||||
|
|
||||||
|
- Reverse proxy (Nginx/NPM) เผยแพร่ Frontend (Next.js) และ Backend (Node.js/Express) ผ่าน HTTPS (HSTS)
|
||||||
|
- Backend เชื่อม MariaDB 10.11 (ข้อมูลหลัก DMS) และแยก n8n + Postgres 16 สำหรับ workflow
|
||||||
|
- RBAC/ABAC ถูกบังคับใช้งานใน middleware + มีชุด SQL (tables → triggers → procedures → views → seed)
|
||||||
|
- ไฟล์จริง (PDF/DWG) เก็บนอก webroot ที่ /share/dms‑data พร้อมมาตรฐานการตั้งชื่อ+โฟลเดอร์
|
||||||
|
- Dev/Prod แยกชัดเจนผ่าน Docker multi‑stage + docker‑compose + โฟลเดอร์ persist logs/config/certs
|
||||||
|
|
||||||
|
### 1.2 Runtime Topology & Trust Boundaries
|
||||||
|
|
||||||
|
```text
|
||||||
|
Internet Clients (Browser)
|
||||||
|
│ HTTPS 443 (HSTS) [QNAP mgmt = 8443]
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Reverse Proxy Layer │
|
||||||
|
│ ├─ Nginx (Alpine) or Nginx Proxy Manager (NPM) │
|
||||||
|
│ ├─ TLS (LE cert; SAN multi‑subdomain) │
|
||||||
|
│ └─ Routes: │
|
||||||
|
│ • /, /_next/* → Frontend (Next.js :3000) │
|
||||||
|
│ • /api/* → Backend (Express :3001) │
|
||||||
|
│ • /pma/* → phpMyAdmin │
|
||||||
|
│ • /n8n/* → n8n (Workflows) │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
│ └──────────┐
|
||||||
|
▼ │
|
||||||
|
Frontend (Next.js) │
|
||||||
|
│ Cookie-based Auth (HttpOnly) │
|
||||||
|
▼ ▼
|
||||||
|
Backend (Node/Express ESM) ─────────► MariaDB 10.11
|
||||||
|
│ │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
Project data (.pdf/.dwg) @ /share/dms-data
|
||||||
|
|
||||||
|
n8n (workflows) ──► Postgres 16 (separate DB for automations)
|
||||||
|
```
|
||||||
|
|
||||||
|
==Trust Boundaries==
|
||||||
|
|
||||||
|
- Public zone: Internet ↔ Reverse proxy
|
||||||
|
- App zone: Reverse proxy ↔ FE/BE containers (internal Docker network)
|
||||||
|
- # Data zone: Backend ↔ Databases (MariaDB, Postgres) + /share/dms-data
|
||||||
|
|
||||||
|
### 1.3 Frontend: Next.js (ESM) / React.js
|
||||||
|
|
||||||
|
#### 1.3.1 Stack & Key libs
|
||||||
|
|
||||||
|
- Next.js (App Router), React, ESM
|
||||||
|
- Tailwind CSS, PostCSS, shadcn/ui (components.json)
|
||||||
|
- Fetch API (credentials include) → Cookie Auth (HttpOnly)
|
||||||
|
|
||||||
|
#### 1.3.2 Directory Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
/frontend/
|
||||||
|
├─ app/
|
||||||
|
│ ├─ login/
|
||||||
|
│ ├─ dashboard/
|
||||||
|
│ ├─ users/
|
||||||
|
│ ├─ correspondences/
|
||||||
|
│ ├─ health/
|
||||||
|
│ └─ layout.tsx / page.tsx (ตาม App Router)
|
||||||
|
├─ public/
|
||||||
|
├─ Dockerfile (multi-stage: dev/prod)
|
||||||
|
├─ package.json
|
||||||
|
├─ next.config.js
|
||||||
|
└─ ...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.3.3 Routing & Layouts
|
||||||
|
|
||||||
|
- Public /login, /health
|
||||||
|
- Protected: /dashboard, /users, /correspondences, ... (client-side guard)
|
||||||
|
- เก็บ middleware.ts (ของเดิม) เพื่อหลีกเลี่ยง regression; ใช้ client‑guard + server action อย่างระมัดระวัง
|
||||||
|
|
||||||
|
#### 1.3.4 Auth Flow (Cookie-based)
|
||||||
|
|
||||||
|
1. ผู้ใช้ submit form /login → POST /api/auth/login (Backend)
|
||||||
|
2. Backend set HttpOnly cookie (JWT) + SameSite=Lax/Strict, Secure
|
||||||
|
3. หน้า protected เรียก GET /api/auth/me เพื่อตรวจสอบสถานะ
|
||||||
|
4. หาก 401 → redirect → /login
|
||||||
|
|
||||||
|
**CORS/Fetch**: เเปิด credentials: 'include' ทุกครั้ง, ตั้ง NEXT_PUBLIC_API_BASE เป็น origin ของ backend ผ่าน proxy (เช่น https://lcbp3.np-dms.work)
|
||||||
|
|
||||||
|
#### 1.3.5 UI/UX
|
||||||
|
|
||||||
|
- Sea‑blue palette, sidebar พับได้, card‑based KPI
|
||||||
|
- ตารางข้อมูลเตรียมรองรับ server‑side DataTables\*\*
|
||||||
|
- shadcn/ui: Button, Card, Badge, Tabs, Dropdown, Tooltip, Switch, etc.
|
||||||
|
|
||||||
|
#### 1.3.6 Config & ENV
|
||||||
|
|
||||||
|
- NEXT_PUBLIC_API_BAS (ex: https://lcbp3.np-dms.work)
|
||||||
|
- Build output แยก dev/prod; ระวัง EACCES บน QNAP → ใช้ user node + ปรับสิทธิ์โวลุ่ม .next/\*
|
||||||
|
|
||||||
|
#### 1.3.7 Error Handling & Observability (FE)
|
||||||
|
|
||||||
|
- Global error boundary (app router) + toast/alert patterns
|
||||||
|
- Network layer: แยก handler สำหรับ 401/403/500 + retry/backoff ที่จำเป็น
|
||||||
|
- Metrics (optional): web‑vitals, UX timing (เก็บฝั่ง n8n หรือ simple logging)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 Backend Architecture (Node.js ESM / Express)
|
||||||
|
|
||||||
|
#### 1.4.1 Stack & Structure
|
||||||
|
|
||||||
|
- Node 20.x, ESM modules, Express\*\*
|
||||||
|
- mysql2/promise, jsonwebtoken, cookie-parser, cors, helmet, winston/morgan
|
||||||
|
|
||||||
|
```text
|
||||||
|
/backend/
|
||||||
|
├─ src/
|
||||||
|
│ ├─ index.js # bootstrap server, CORS, cookies, health
|
||||||
|
│ ├─ routes/
|
||||||
|
│ │ ├─ auth.js # /api/auth/* (login, me, logout)
|
||||||
|
│ │ ├─ users.js # /api/users/*
|
||||||
|
│ │ ├─ correspondences.js # /api/correspondences/*
|
||||||
|
│ │ ├─ drawings.js # /api/drawings/*
|
||||||
|
│ │ ├─ rfas.js # /api/rfas/*
|
||||||
|
│ │ └─ transmittals.js # /api/transmittals/*
|
||||||
|
│ ├─ middleware/
|
||||||
|
│ │ ├─ authGuard.js # verify JWT from cookie
|
||||||
|
│ │ ├─ requirePermission.js# RBAC/ABAC enforcement
|
||||||
|
│ │ ├─ errorHandler.js
|
||||||
|
│ │ └─ requestLogger.js
|
||||||
|
│ ├─ db/
|
||||||
|
│ │ ├─ pool.js # createPool, sane defaults
|
||||||
|
│ │ └─ models/ # query builders (User, Drawing, ...)
|
||||||
|
│ ├─ utils/
|
||||||
|
│ │ ├─ hash.js (bcrypt/argon2)
|
||||||
|
│ │ ├─ jwt.js
|
||||||
|
│ │ ├─ pagination.js
|
||||||
|
│ │ └─ responses.js
|
||||||
|
│ └─ config/
|
||||||
|
│ └─ index.js # env, constants
|
||||||
|
├─ Dockerfile
|
||||||
|
└─ package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.4.2 Request Lifecycle
|
||||||
|
|
||||||
|
1. helmet + cors (allow specific origin; credentials true)
|
||||||
|
2. cookie-parser, json limit (e.g., 2MB)
|
||||||
|
3. requestLogger → trace + response time
|
||||||
|
4. Route handler → authGuard (protected) → requirePermission (per‑route) → Controller
|
||||||
|
5. Error bubbles → errorHandler (JSON shape, status map)
|
||||||
|
|
||||||
|
#### 1.4.3 Auth & RBAC/ABAC
|
||||||
|
|
||||||
|
- JWT ใน HttpOnly cookie; Claims: sub (user_id), roles, exp
|
||||||
|
- authGuard: ตรวจ token → แนบ req.user
|
||||||
|
- requirePermission: เช็ค permission ตามเส้นทาง/วิธี; แผนขยาย ABAC (เช่น project scope, owner, doc state)
|
||||||
|
- Roles/Permissions ถูก seed ใน SQL; มี view เมทริกซ์ เพื่อ debug (เช่น v_role_permission_matrix)
|
||||||
|
|
||||||
|
\*\*ตัวอย่าง pseudo requirePermission(permission)
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const requirePermission = (perm) => async (req, res, next) => {
|
||||||
|
if (!req.user) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
|
const ok = await checkPermission(req.user.user_id, perm, req.context);
|
||||||
|
if (!ok) return res.status(403).json({ error: "Forbidden" });
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.4.4 Database Access & Pooling
|
||||||
|
|
||||||
|
- createPool({ connectionLimit: 10~25, queueLimit: 0, waitForConnections: true })
|
||||||
|
- ใช้ parameterized queries เสมอ; ปรับ sql_mode ที่จำเป็นใน my.cnf
|
||||||
|
|
||||||
|
#### 1.4.5 File Storage & Secure Download
|
||||||
|
|
||||||
|
- Root: /share/dms‑data
|
||||||
|
- โครงโฟลเดอร์: {module}/{yyyy}/{mm}/{entityId}/ + ชื่อไฟล์ตามมาตรฐาน (เช่น DRW-code-REV-rev.pdf)
|
||||||
|
- Endpoint download: ตรวจสิทธิ์ (RBAC/ABAC) → res.sendFile()/stream; ป้องกัน path traversal
|
||||||
|
- MIME allowlist + size limit + virus scan (optional; ภายหลัง)
|
||||||
|
|
||||||
|
#### 1.4.6 Health & Readiness
|
||||||
|
|
||||||
|
- GET /api/health → { ok: true }
|
||||||
|
- (optional) /api/ready ตรวจ DB ping + disk space (dms‑data)
|
||||||
|
|
||||||
|
#### 1.4.7 Config & ENV (BE)
|
||||||
|
|
||||||
|
- DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME
|
||||||
|
- JWT_SECRET, COOKIE_NAME, COOKIE_SAMESITE, COOKIE_SECURE
|
||||||
|
- CORS_ORIGIN, LOG_LEVEL, APP_BASE_URL
|
||||||
|
- FILE_ROOT=/share/dms-data
|
||||||
|
|
||||||
|
#### 1.4.8 Logging
|
||||||
|
|
||||||
|
- Access log (morgan) + App log (winston) → /share/Container/dms/logs/backend/
|
||||||
|
- รูปแบบ JSON (timestamp, level, msg, reqId) + daily rotation (logrotate/container‑side)
|
||||||
|
|
||||||
|
### 1.5 Database (MariaDB 10.11)
|
||||||
|
|
||||||
|
#### 1.5.1 Schema Overview (ย่อ)
|
||||||
|
|
||||||
|
- RBAC core: users, roles, permissions, user_roles, role_permissions
|
||||||
|
- Domain: drawings, contracts, correspondences, rfas, transmittals, organizations, projects, ...
|
||||||
|
- Audit: audit_logs (แผนขยาย), deleted_at (soft delete, แผนงาน)
|
||||||
|
|
||||||
|
```text
|
||||||
|
[users]──<user_roles>──[roles]──<role_permissions>──[permissions]
|
||||||
|
│
|
||||||
|
└── activities/audit_logs (future expansion)
|
||||||
|
|
||||||
|
[drawings]──<mapping>──[contracts]
|
||||||
|
[rfas]──<links>──[drawings]
|
||||||
|
[correspondences] (internal/external flag)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.5.2 Init SQL Pipeline
|
||||||
|
|
||||||
|
1. 01\_\*\_deploy_table_rbac.sql — สร้างตารางหลักทั้งหมด + RBAC
|
||||||
|
2. 02\_\*\_triggers.sql — บังคับ data rules, auto‑audit fields
|
||||||
|
3. 03\_\*\_procedures_handlers.sql — upsert/bulk handlers (เช่น sp_bulk_import_contract_dwg)
|
||||||
|
4. 04\_\*\_views.sql — รายงาน/เมทริกซ์สิทธิ์ (v_role_permission_matrix, etc.)
|
||||||
|
5. 05\_\*\_seed_data.sql — ค่าพื้นฐาน domain (project, categories, statuses)
|
||||||
|
6. 06\_\*\_seed_users.sql — บัญชีเริ่มต้น (superadmin, editors, viewers)
|
||||||
|
7. 07\_\*\_seed_contract_dwg.sql — ข้อมูลตัวอย่างแบบสัญญา
|
||||||
|
|
||||||
|
#### 1.5.3 Indexing & Performance
|
||||||
|
|
||||||
|
- Composite indexes ตามคอลัมน์ filter/sort (เช่น (project_id, updated_at DESC))
|
||||||
|
- Full‑text index (optional) สำหรับ advanced search
|
||||||
|
- Query plan review (EXPLAIN) + เพิ่ม covering index ตามรายงาน
|
||||||
|
|
||||||
|
#### 1.5.4 MySQL/MariaDB Config (my.cnf — แนวทาง)
|
||||||
|
|
||||||
|
```conf
|
||||||
|
[mysqld]
|
||||||
|
innodb_buffer_pool_size = 4G # ปรับตาม RAM/QNAP
|
||||||
|
innodb_log_file_size = 512M
|
||||||
|
innodb_flush_log_at_trx_commit = 1
|
||||||
|
max_connections = 200
|
||||||
|
sql_mode = STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION
|
||||||
|
character-set-server = utf8mb4
|
||||||
|
collation-server = utf8mb4_unicode_ci
|
||||||
|
```
|
||||||
|
|
||||||
|
> ปรับค่าให้เหมาะกับ workload จริง + เฝ้าดู IO/CPU ของ QNAP
|
||||||
|
|
||||||
|
#### 1.5.5 Backup/Restore
|
||||||
|
|
||||||
|
- Logical backup: mysqldump --routines --triggers --single-transaction
|
||||||
|
- Physical (snapshot QNAP) + schedule ผ่าน n8n/cron
|
||||||
|
- เก็บสำเนา off‑NAS (encrypted)
|
||||||
|
|
||||||
|
### 1.6 Reverse Proxy & TLS
|
||||||
|
|
||||||
|
#### 1.6.1 Nginx (Alpine) — ตัวอย่าง server block
|
||||||
|
|
||||||
|
> สำคัญ: บนสภาพแวดล้อมนี้ ให้ใช้คนละบรรทัด:
|
||||||
|
> listen 443 ssl;
|
||||||
|
> http2 on;
|
||||||
|
> หลีกเลี่ยง listen 443 ssl http2;
|
||||||
|
|
||||||
|
```conf
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name lcbp3.np-dms.work;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name lcbp3.np-dms.work;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; preload" always;
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Next.js static
|
||||||
|
location /_next/ {
|
||||||
|
proxy_pass http://frontend:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
location /api/ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_pass http://backend:3001;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# phpMyAdmin (sub-path)
|
||||||
|
location /pma/ {
|
||||||
|
proxy_pass http://phpmyadmin:80/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# n8n
|
||||||
|
location /n8n/ {
|
||||||
|
proxy_pass http://n8n:5678/;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.6.2 Nginx Proxy Manager (NPM) — Tips
|
||||||
|
|
||||||
|
- ระวังอย่าใส่ proxy_http_version ซ้ำซ้อน (duplicate directive) ใน Advanced
|
||||||
|
- ถ้าต้องแก้ไฟล์ด้านใน NPM → ระวังไฟล์ใน /data/nginx/proxy_host/\*.conf
|
||||||
|
- จัดการ certificate / SAN หลาย sub‑domain ใน UI แต่ mainten ดีเรื่อง symlink/renew
|
||||||
|
|
||||||
|
#### 1.6.3 TLS & Certificates
|
||||||
|
|
||||||
|
- Let’s Encrypt (HTTP‑01 webroot/standalone) + HSTS
|
||||||
|
- QNAP mgmt เปลี่ยนเป็น 8443 → พอร์ต 443 public ว่างสำหรับ Nginx/NPM
|
||||||
|
|
||||||
|
### 1.7 Docker Compose Topology
|
||||||
|
|
||||||
|
#### 1.7.1 Services (สรุป)
|
||||||
|
|
||||||
|
- frontend (Next.js) :3000
|
||||||
|
- backend (Express) :3001
|
||||||
|
- mariadb (10.11) :3306 (internal)
|
||||||
|
- phpmyadmin :80 (internal)
|
||||||
|
- nginx or npm :80/443 (published)
|
||||||
|
- n8n :5678 (internal)
|
||||||
|
- postgres_n8n (16-alpine)
|
||||||
|
- pgadmin4
|
||||||
|
|
||||||
|
#### 1.7.2 Volumes & Paths
|
||||||
|
|
||||||
|
```text
|
||||||
|
/share/Container/dms/
|
||||||
|
├─ mariadb/data
|
||||||
|
├─ mariadb/init/*.sql
|
||||||
|
├─ backend/ (code)
|
||||||
|
├─ frontend/ (code)
|
||||||
|
├─ phpmyadmin/{sessions,tmp,config.user.inc.php}
|
||||||
|
├─ nginx/{nginx.conf,dms.conf,certs/}
|
||||||
|
├─ n8n, n8n-postgres, n8n-cache
|
||||||
|
└─ logs/{backend,frontend,nginx,pgadmin,phpmyadmin,postgres_n8n}
|
||||||
|
/share/dms-data (pdf/dwg storage)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.7.3 Healthchecks (suggested)
|
||||||
|
|
||||||
|
- backend:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl http://localhost:3001/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
- frontend: curl /health (simple JSON)
|
||||||
|
- mariadb: mysqladmin ping with credentials
|
||||||
|
- nginx: nginx -t at startup
|
||||||
|
|
||||||
|
#### 1.7.4 Security Hardening
|
||||||
|
|
||||||
|
- รัน container ด้วย user non‑root (user: node สำหรับ FE/BE)
|
||||||
|
- จำกัด capabilities; read‑only FS (ยกเว้นโวลุ่มจำเป็น)
|
||||||
|
- เฉพาะ backend เมานต์ /share/dms-data
|
||||||
|
|
||||||
|
### 1.8 Observability, Ops, and Troubleshooting
|
||||||
|
|
||||||
|
#### 1.8.1 Logs
|
||||||
|
|
||||||
|
- Frontend → /logs/frontend/\*
|
||||||
|
- Backend → /logs/backend/\* (app/access/error)
|
||||||
|
- Nginx/NPM → /logs/nginx/\*
|
||||||
|
- MariaDB → default datadir log + slow query (เปิดใน my.cnf หากต้องการ)
|
||||||
|
|
||||||
|
#### 1.8.2 Common Issues & Playbooks
|
||||||
|
|
||||||
|
- 401 Unauthenticated: ตรวจ authGuard → JWT cookie มี/หมดอายุ → เวลา server/FE sync → CORS credentials: true
|
||||||
|
- EACCES Next.js: สิทธิ์ .next/\* + run as`node, โวลุ่ม map ถูก user:group
|
||||||
|
- NPM duplicate directive: ลบซ้ำ proxy_http_version ใน Advanced / ตรวจ proxy_host/\*.conf
|
||||||
|
- LE cert path/symlink: ตรวจ /etc/letsencrypt/live/npm-\* symlink ชี้ถูก
|
||||||
|
- DB field not found: ตรวจ schema vs code (migration/init SQL) → sync ให้ตรง
|
||||||
|
|
||||||
|
#### 1.8.3 Performance Guides
|
||||||
|
|
||||||
|
- Backend: keep‑alive, gzip/deflate at proxy, pool 10–25, paginate, avoid N+1
|
||||||
|
- Frontend: prefetch critical routes, cache static, image optimization
|
||||||
|
- DB: เพิ่ม index จุด filter, analyze query (EXPLAIN), ปรับ buffer pool
|
||||||
|
|
||||||
|
### 1.9 Security & Compliance
|
||||||
|
|
||||||
|
- HTTPS only + HSTS (preload)
|
||||||
|
- CORS: allow list เฉพาะ FE origin; Access-Control-Allow-Credentials: true
|
||||||
|
- Cookie: HttpOnly, Secure, SameSite=Lax/Strict
|
||||||
|
- Input Validation: celebrate/zod (optional) + sanitize
|
||||||
|
- Rate limiting: per IP/route (optional)
|
||||||
|
- AuditLog: วางแผนเพิ่ม ครอบคลุม CRUD + mapping (actor, action, entity, before/after)
|
||||||
|
- Backups: DB + /share/dms-data + config (encrypted off‑NAS)
|
||||||
|
|
||||||
|
### 1.10 Backlog → Architecture Mapping
|
||||||
|
|
||||||
|
1. RBAC Enforcement ครบ → เติม requirePermission ทุก route + test matrix ผ่าน view
|
||||||
|
2. AuditLog ครบ CRUD/Mapping → trigger + table audit_logs + BE hook
|
||||||
|
3. Upload/Download จริงของ Drawing Revisions → BE endpoints + virus scan (optional)
|
||||||
|
4. Dashboard KPI → BE summary endpoints + FE cards/charts
|
||||||
|
5. Server‑side DataTables → paging/sort/filter + indexesรองรับ
|
||||||
|
6. รายงาน Export CSV/Excel/PDF → BE export endpoints + FE buttons
|
||||||
|
7. Soft delete (deleted_at) → BE filter default scope + restore endpoint
|
||||||
|
8. Validation เข้ม → celebrate/zod schema + consistent error shape
|
||||||
|
9. Indexing/Perf → slow query log + EXPLAIN review
|
||||||
|
10. Job/Cron Deadline Alerts → n8n schedule + SMTP
|
||||||
|
|
||||||
|
### 1.11 Port & ENV Matrix (Quick Ref)
|
||||||
|
|
||||||
|
| Component | Ports | Key ENV |
|
||||||
|
| Nginx/NPM | 80/443 (public) | SSL paths, HSTS |
|
||||||
|
| Frontend | 3000 (internal) | NEXT*PUBLIC_API_BASE |
|
||||||
|
| Backend | 3001 (internal) | DB*\*, JWT*SECRET, CORS_ORIGIN, FILE_ROOT |
|
||||||
|
| MariaDB | 3306 (internal) | MY_CNF, credentials |
|
||||||
|
| n8n | 5678 (internal) | N8N*, webhook URL under /n8n/ |
|
||||||
|
| Postgres | 5432 (internal) | n8n DB |
|
||||||
|
|
||||||
|
QNAP mgmt: 8443 (already moved)
|
||||||
|
|
||||||
|
### 1.12 Sample Snippets
|
||||||
|
|
||||||
|
#### 1.12.1 Backend CORS (credentials)
|
||||||
|
|
||||||
|
```js
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: ["https://lcbp3.np-dms.work"],
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.12.2 Secure Download (guarded)
|
||||||
|
|
||||||
|
```js
|
||||||
|
router.get(
|
||||||
|
"/files/:module/:id/:filename",
|
||||||
|
authGuard,
|
||||||
|
requirePermission("file.read"),
|
||||||
|
async (req, res) => {
|
||||||
|
const { module, id, filename } = req.params;
|
||||||
|
// 1) ABAC: verify user can access this module/entity
|
||||||
|
const ok = await canReadFile(req.user.user_id, module, id);
|
||||||
|
if (!ok) return res.status(403).json({ error: "Forbidden" });
|
||||||
|
|
||||||
|
const abs = path.join(FILE_ROOT, module, id, filename);
|
||||||
|
if (!abs.startsWith(FILE_ROOT))
|
||||||
|
return res.status(400).json({ error: "Bad path" });
|
||||||
|
return res.sendFile(abs);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.12.3 Healthcheck
|
||||||
|
|
||||||
|
```js
|
||||||
|
router.get("/health", (req, res) => res.json({ ok: true }));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13 Deployment Workflow (Suggested)
|
||||||
|
|
||||||
|
1. Git (Gitea) branch strategy feature/\* → PR → main
|
||||||
|
2. Build images (dev/prod) via Dockerfile multi‑stage; pin Node/MariaDB versions
|
||||||
|
3. docker compose up -d --build จาก /share/Container/dms
|
||||||
|
4. Validate: /health, /api/health, login roundtrip
|
||||||
|
5. Monitor logs + baseline perf; run SQL smoke tests (views/triggers/procs)
|
||||||
|
|
||||||
|
### 14 Appendix
|
||||||
|
|
||||||
|
- Naming conventions: snake_case DB, camelCase JS
|
||||||
|
- Timezones: store UTC in DB; display in app TZ (+07:00)
|
||||||
|
- Character set: UTF‑8 (utf8mb4_unicode_ci)
|
||||||
|
- Large file policy: size limit (e.g., 50–200MB), allowlist extensions
|
||||||
|
- Retention: archive strategy for old revisions (optional)
|
||||||
|
|
||||||
|
## บทบาท: คุณคือ Programmer และ Document Engineer ที่เชี่ยวชาญ
|
||||||
|
|
||||||
|
1. การพัฒนาเว็บแอป (Web Application Development)
|
||||||
|
2. Configuration of Container Station on QNAP
|
||||||
|
3. Database: mariadb:10.11
|
||||||
|
4. Database management: phpmyadmin:5-apache
|
||||||
|
5. Backend: node:.js (ESM)
|
||||||
|
6. Frontend: next.js, react
|
||||||
|
7. Workflow automation: n8n:
|
||||||
|
8. Workflow database: postgres:16-alpine
|
||||||
|
9. Workflow database management: pgadmin4
|
||||||
|
10. Reverse proxy: nginx:1.27-alpine
|
||||||
|
11. linux on QNAP
|
||||||
|
12. การจัดการฐานข้อมูล (Database Management)
|
||||||
|
13. การวิเคราะห์ฐานข้อมูล (Database Analysis)
|
||||||
|
14. การจัดการฐานข้อมูลเชิงสัมพันธ์ (Relational Databases)
|
||||||
|
15. ภาษา SQL
|
||||||
|
16. RBAC
|
||||||
|
|
||||||
|
## 2. ระบบที่ใช้
|
||||||
|
|
||||||
|
## Server
|
||||||
|
|
||||||
|
- ใช้ Container Station เป็น SERVER บน QNAP (Model: TS-473A, RAM: 32GB, CPU: AMD Ryzen V1500B 4 cores 8 threads) **เปลี่ยน port 443 ของ QNAP เป็น 8443 แล้ว**
|
||||||
|
|
||||||
|
## การพัฒนาโครงการ
|
||||||
|
|
||||||
|
- ด้วย Visual Studio Code บน Windows 11
|
||||||
|
- ใช้ ๊ UI ของ Container Station เป็นหลัก
|
||||||
|
|
||||||
|
## โครงสร้างโฟลเดอร์ (บน QNAP)
|
||||||
|
|
||||||
# ระบบที่ใช้
|
|
||||||
## Server
|
|
||||||
- ใช้ Container Station เป็น SERVER บน QNAP (Model: TS-473A, RAM: 32GB, CPU: AMD Ryzen V1500B 4 cores 8 threads)
|
|
||||||
*** เปลี่ยน port 443 ของ QNAP เป็น 8443 แล้ว ***
|
|
||||||
## การพัฒนาโครงการ
|
|
||||||
- ด้วย Visual Studio Code บน Windows 11
|
|
||||||
- ใช้ ๊ UI ของ Container Station เป็นหลัก
|
|
||||||
## โครงสร้างโฟลเดอร์ (บน QNAP)
|
|
||||||
/share/Container/dms/
|
/share/Container/dms/
|
||||||
├─ docker-compose.yml # Create โดย UI Container Station
|
├─ docker-compose.yml # Create โดย UI Container Station
|
||||||
├─ mariadb/
|
├─ mariadb/
|
||||||
@@ -75,31 +741,37 @@
|
|||||||
├─ n8n-cache/
|
├─ n8n-cache/
|
||||||
├─ n8n-postgres/
|
├─ n8n-postgres/
|
||||||
└─ logs/
|
└─ logs/
|
||||||
├─ backend/
|
├─ backend/
|
||||||
├─ frontend/
|
├─ frontend/
|
||||||
├─ nginx/
|
├─ nginx/
|
||||||
├─ pgadmin/
|
├─ pgadmin/
|
||||||
├─ phpmyadmin/
|
├─ phpmyadmin/
|
||||||
└─ postgres_n8n/
|
└─ postgres_n8n/
|
||||||
/share/dms-data # เก็บข้อมมูล .pdf, .dwg แยกตาม correspondences, documents
|
/share/dms-data # เก็บข้อมมูล .pdf, .dwg แยกตาม correspondences, documents
|
||||||
|
|
||||||
|
|
||||||
# ภาษา: ใช้ภาษาไทยในการโต้ตอบ ยกเว้น ศัพท์เทคนิคหรือศัพท์เฉพาะทาง
|
|
||||||
|
|
||||||
# ไฟล์ที่ ีupload
|
|
||||||
- Dockerfile ของ backend
|
|
||||||
- package.json ของ backend
|
|
||||||
- docker-compose.yml ชอง Container station
|
|
||||||
- nginx.conf, dms.conf ของ nginx
|
|
||||||
- dms_v0_5_0_data_v5_1_sql.zip ประกอบด้วย
|
|
||||||
- 01_dms_data_v5_1_deploy_table_rbac.sql # Create all data table & RBAC table here!
|
|
||||||
- 02_dms_data_v5_1_triggers.sql # Create all triggers here!
|
|
||||||
- 03_dms_data_v5_1_procedures_handlers.sql # Create all procedures here!
|
|
||||||
- 04_dms_data_v5_1_views.sql # Create all views here!
|
|
||||||
- 05 dms_data_v5_1_seeก_data.sql # Seed nescesary data here!
|
|
||||||
- 06_dms_data_v5_1_seed_users.sql # Seed users data here!
|
|
||||||
|
|
||||||
# งานที่ต้องการ:
|
# งานที่ต้องการ:
|
||||||
- ไม่ใช้ .env เด็ดขาด Container Station ไม่รองรับ และ docker-compose.yml ได้ทดสอบ รันบน Container station มาแล้ว
|
|
||||||
- Code ของ backend ทั้งหมด
|
- ไม่ใช้ .env เด็ดขาด Container Station ไม่รองรับ และ docker-compose.yml ได้ทดสอบ รันบน Container station มาแล้ว
|
||||||
- การทดสอบระบบ backend ทุกส่วน ให้พร้อม สำหรับ frontend
|
- Code ของ backend ทั้งหมด
|
||||||
|
- การทดสอบระบบ backend ทุกส่วน ให้พร้อม สำหรับ frontend
|
||||||
|
|
||||||
|
# กรณี 2: มี Git อยู่แล้ว (มี main อยู่)
|
||||||
|
|
||||||
|
2.1 อัปเดต main ให้ตรงล่าสุดก่อนแตกบร้านช์
|
||||||
|
|
||||||
|
cd /share/Container/dms
|
||||||
|
git checkout main
|
||||||
|
git pull --ff-only # ถ้าเชื่อม remote อยู่
|
||||||
|
git tag -f stable-$(date +%F) # tag จุดเสถียรปัจจุบัน
|
||||||
|
|
||||||
|
2.2 แตก branch งาน Dashboard
|
||||||
|
git checkout -b feature/dashboard-update-$(date +%y%m%d)
|
||||||
|
git checkout -b feature/dashboard-update-251004
|
||||||
|
|
||||||
|
2.3 ทำงาน/คอมมิตตามปกติ
|
||||||
|
|
||||||
|
# แก้ไฟล์ frontend/app/dashboard/\* และที่เกี่ยวข้อง
|
||||||
|
|
||||||
|
git add frontend/app/dashboard
|
||||||
|
git commit -m "feat(dashboard): เพิ่มส่วนจัดการ user"
|
||||||
|
git push -u origin feature/dashboard-update-251004
|
||||||
|
|||||||
96
b.env
96
b.env
@@ -1,96 +0,0 @@
|
|||||||
TZ=Asia/Bangkok
|
|
||||||
GENERIC_TIMEZONE=Asia/Bangkok
|
|
||||||
PUBLIC_DOMAIN=np-dms.work
|
|
||||||
|
|
||||||
PUBLIC_FRONTEND_URL=https://lcbp3.np-dms.work
|
|
||||||
PUBLIC_BACKEND_URL=https://lcbp3.np-dms.work/api
|
|
||||||
PUBLIC_N8N_URL=https://lcbp3.np-dms.work/n8n
|
|
||||||
|
|
||||||
MARIADB_HOST=mariadb
|
|
||||||
MARIADB_PORT=3306
|
|
||||||
MARIADB_ROOT_PASSWORD=Center#2025
|
|
||||||
MARIADB_DATABASE=dms
|
|
||||||
MARIADB_USER=center
|
|
||||||
MARIADB_PASSWORD=Center#2025
|
|
||||||
|
|
||||||
# MARIADB_HOST_PORT=7307
|
|
||||||
# BACKEND_HOST_PORT=7001
|
|
||||||
# FRONTEND_HOST_PORT=7000
|
|
||||||
# PHPMYADMIN_HOST_PORT=7070
|
|
||||||
NGINX_HTTP_HOST_PORT=80
|
|
||||||
NGINX_HTTPS_HOST_PORT=443
|
|
||||||
N# 8N_HOST_PORT=7081
|
|
||||||
|
|
||||||
NODE_ENV=production
|
|
||||||
JWT_SECRET=8b0df02e4aee9f9f79a4f2d8ba77b0b82c1ee3446b68cb0bae94ab54d60f8d9e
|
|
||||||
JWT_EXPIRES_IN=12h
|
|
||||||
PASSWORD_SALT_ROUNDS=10
|
|
||||||
RATE_LIMIT_WINDOW_MS=900000
|
|
||||||
RATE_LIMIT_MAX=200
|
|
||||||
CORS_ORIGINS=https://lcbp3.np-dms.work,http://localhost:7000,http://192.168.20.248:7000
|
|
||||||
|
|
||||||
|
|
||||||
NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
PMA_HOST=mariadb
|
|
||||||
PMA_PORT=3306
|
|
||||||
PMA_ABSOLUTE_URI=https://lcbp3.np-dms.work.com/pma/
|
|
||||||
|
|
||||||
UPLOAD_LIMIT=256M
|
|
||||||
MEMORY_LIMIT=512M
|
|
||||||
|
|
||||||
NGINX_SERVER_NAME=np-dms.work.com
|
|
||||||
NGINX_PROXY_READ_TIMEOUT=300
|
|
||||||
|
|
||||||
# QNAP_SSL_CERT_HOST=/etc/qnap-ssl/combine
|
|
||||||
# QNAP_SSL_KEY_HOST=/etc/qnap-ssl/key
|
|
||||||
# NGINX_SSL_CERT=/etc/nginx/certs/fullchain.pem
|
|
||||||
# NGINX_SSL_KEY=/etc/nginx/certs/privkey.pem
|
|
||||||
# NGINX_SSL_KEY=/etc/nginx/certs
|
|
||||||
QNAP_SSL_CERT=/etc/config/QcloudSSLCertificate/cert
|
|
||||||
NGINX_SSL_CERT=/etc/qnap-ssl
|
|
||||||
|
|
||||||
N8N_BASIC_AUTH_ACTIVE=true
|
|
||||||
N8N_BASIC_AUTH_USER=n8n
|
|
||||||
N8N_BASIC_AUTH_PASSWORD=Center#2025
|
|
||||||
N8N_PATH=/n8n/
|
|
||||||
N8N_PROTOCOL=https
|
|
||||||
N8N_PROXY_HOPS=1
|
|
||||||
N8N_SECURE_COOKIE=true
|
|
||||||
N8N_HOST=dcs.mycloudnas.com
|
|
||||||
N8N_PORT=5678
|
|
||||||
N8N_EDITOR_BASE_URL=https://lcbp3.np-dms.work/n8n/
|
|
||||||
WEBHOOK_URL=https://lcbp3.np-dms.work/n8n/
|
|
||||||
N8N_ENCRYPTION_KEY=9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI
|
|
||||||
# --- n8n → MariaDB ---
|
|
||||||
# DB_TYPE=mysqldb
|
|
||||||
# DB_MYSQLDB_HOST=mariadb
|
|
||||||
# DB_MYSQLDB_PORT=3306
|
|
||||||
# DB_MYSQLDB_DATABASE=n8n
|
|
||||||
# DB_MYSQLDB_USER=n8n_user
|
|
||||||
# DB_MYSQLDB_PASSWORD=Center#2025 # เปลี่ยนเป็นรหัสแข็งแรงของคุณ
|
|
||||||
|
|
||||||
# ==== n8n → PostgreSQL (แทน MariaDB/MySQL) ====
|
|
||||||
DB_TYPE=postgresdb
|
|
||||||
DB_POSTGRESDB_HOST=postgres_n8n
|
|
||||||
DB_POSTGRESDB_PORT=5432
|
|
||||||
DB_POSTGRESDB_DATABASE=n8n
|
|
||||||
DB_POSTGRESDB_USER=n8n
|
|
||||||
DB_POSTGRESDB_PASSWORD=Center#2025
|
|
||||||
# path โฟลเดอร์ n8n เดิม (มี database.sqlite)
|
|
||||||
# HOST_N8N=/share/Container/dms/n8n
|
|
||||||
|
|
||||||
|
|
||||||
HOST_BASE=/share/Container/dms
|
|
||||||
HOST_MARIADB=${HOST_BASE}/mariadb
|
|
||||||
HOST_BACKEND=${HOST_BASE}/backend
|
|
||||||
HOST_FRONTEND=${HOST_BASE}/frontend
|
|
||||||
HOST_PHPMYADMIN=${HOST_BASE}/phpmyadmin
|
|
||||||
HOST_NGINX=${HOST_BASE}/nginx
|
|
||||||
HOST_LOGS=${HOST_BASE}/logs
|
|
||||||
HOST_SCRIPTS=${HOST_BASE}/scripts
|
|
||||||
HOST_N8N=/share/Container/dms/n8n
|
|
||||||
HOST_N8N_CACHE=${HOST_BASE}/n8n-cache
|
|
||||||
HOST_DATA=/share/dms-data
|
|
||||||
# BACKEND_LOG_DIR=${HOST_LOGS}/backend
|
|
||||||
BACKEND_LOG_DIR=/app/logs
|
|
||||||
29
backend/.backup/backend build.md
Normal file
29
backend/.backup/backend build.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Backend build
|
||||||
|
|
||||||
|
## วิธีสร้าง package-lock.json ด้วย Docker
|
||||||
|
|
||||||
|
### 1. เช็ค uid:gid ของโฟลเดอร์โปรเจกต์บน QNAP
|
||||||
|
|
||||||
|
stat -c "%u:%g" .
|
||||||
|
|
||||||
|
### 2. ใช้ค่าที่ได้มาแทน UID:GID
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-v "/share/Container/dms/frontend:/app" -w /app \
|
||||||
|
--user UID:GID -e HOME=/tmp \
|
||||||
|
node:20-alpine sh -lc 'mkdir -p /tmp && npm install --package-lock-only --ignore-scripts'
|
||||||
|
```
|
||||||
|
|
||||||
|
สร้าง package-lock.json โดย ไม่ติดตั้ง node_modules
|
||||||
|
|
||||||
|
--user $(id -u):$(id -g) ทำให้ไฟล์ที่ได้เป็นเจ้าของโดยยูสเซอร์ปัจจุบัน (กันปัญหา root-owned)
|
||||||
|
|
||||||
|
## ขั้นตอน Build บน QNAP
|
||||||
|
|
||||||
|
docker compose -f docker-backend-build.yml build --no-cache 2>&1 | tee backend_build.log
|
||||||
|
|
||||||
|
## สำหรับ build local
|
||||||
|
|
||||||
|
cd backend
|
||||||
|
docker build -t dms-backend:dev --target dev .
|
||||||
BIN
backend/.backup/backend_tree.txt
Normal file
BIN
backend/.backup/backend_tree.txt
Normal file
Binary file not shown.
0
backend/ed25519 → backend/.backup/ed25519
Executable file → Normal file
0
backend/ed25519 → backend/.backup/ed25519
Executable file → Normal file
0
backend/ed25519.pub → backend/.backup/ed25519.pub
Executable file → Normal file
0
backend/ed25519.pub → backend/.backup/ed25519.pub
Executable file → Normal file
64
backend/.backup/fix-bearer-index.patch.diff
Normal file
64
backend/.backup/fix-bearer-index.patch.diff
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
diff --git a/src/index.js b/src/index.js
|
||||||
|
--- a/src/index.js
|
||||||
|
+++ b/src/index.js
|
||||||
|
@@ -1,9 +1,8 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import express from "express";
|
||||||
|
-import cookieParser from "cookie-parser";
|
||||||
|
import cors from "cors";
|
||||||
|
|
||||||
|
import sql from "./db/index.js";
|
||||||
|
import healthRouter from "./routes/health.js";
|
||||||
|
import { authJwt } from "./middleware/authJwt.js";
|
||||||
|
@@ -64,7 +63,7 @@
|
||||||
|
// ✅ อยู่หลัง NPM/Reverse proxy → ให้ trust proxy เพื่อให้ cookie secure / proto ทำงานถูก
|
||||||
|
app.set("trust proxy", 1);
|
||||||
|
|
||||||
|
-// CORS แบบกำหนด origin ตามรายการที่อนุญาต + อนุญาต credentials (จำเป็นสำหรับ cookie)
|
||||||
|
+// ✅ CORS สำหรับ Bearer token: ไม่ต้องใช้ credentials (ไม่มีคุกกี้)
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin(origin, cb) {
|
||||||
|
if (!origin) return cb(null, true); // server-to-server / curl
|
||||||
|
return cb(null, ALLOW_ORIGINS.includes(origin));
|
||||||
|
},
|
||||||
|
- credentials: true,
|
||||||
|
+ credentials: false,
|
||||||
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
|
- allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
|
||||||
|
+ allowedHeaders: [
|
||||||
|
+ "Content-Type",
|
||||||
|
+ "Authorization",
|
||||||
|
+ "X-Requested-With",
|
||||||
|
+ "Accept",
|
||||||
|
+ "Origin",
|
||||||
|
+ "Referer",
|
||||||
|
+ "User-Agent",
|
||||||
|
+ "Cache-Control",
|
||||||
|
+ "Pragma"
|
||||||
|
+ ],
|
||||||
|
exposedHeaders: ["Content-Disposition", "Content-Length"],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// preflight
|
||||||
|
app.options(
|
||||||
|
"*",
|
||||||
|
cors({
|
||||||
|
origin(origin, cb) {
|
||||||
|
if (!origin) return cb(null, true);
|
||||||
|
return cb(null, ALLOW_ORIGINS.includes(origin));
|
||||||
|
},
|
||||||
|
- credentials: true,
|
||||||
|
+ credentials: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
-app.use(cookieParser());
|
||||||
|
+// ❌ ไม่ต้อง parse cookie แล้ว (เราไม่ใช้คุกกี้สำหรับ auth)
|
||||||
|
+// app.use(cookieParser());
|
||||||
|
|
||||||
|
// Payload limits
|
||||||
|
app.use(express.json({ limit: "10mb" }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||||
|
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
.git
|
.git
|
||||||
|
.vscode
|
||||||
|
.backup
|
||||||
node_modules
|
node_modules
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
Dockerfile*
|
Dockerfile*.*
|
||||||
|
*.yml
|
||||||
README*.md
|
README*.md
|
||||||
coverage
|
coverage
|
||||||
tmp
|
tmp
|
||||||
|
|||||||
15
backend/.eslintrc.json
Normal file
15
backend/.eslintrc.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"node": true,
|
||||||
|
"es2021": true,
|
||||||
|
"jest": true
|
||||||
|
},
|
||||||
|
"extends": ["eslint:recommended", "plugin:prettier/recommended"],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"prettier/prettier": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
backend/.gitignore
vendored
Normal file
3
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
# Ignore Nginx Proxy Manager data
|
||||||
|
/npm/
|
||||||
7
backend/.prettierrc.json
Normal file
7
backend/.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"arrowParens": "always",
|
||||||
|
"printWidth": 80
|
||||||
|
}
|
||||||
@@ -67,3 +67,4 @@ EXPOSE 3001
|
|||||||
HEALTHCHECK --interval=30s --timeout=5s --retries=10 \
|
HEALTHCHECK --interval=30s --timeout=5s --retries=10 \
|
||||||
CMD wget -qO- http://127.0.0.1:3001/health || exit 1
|
CMD wget -qO- http://127.0.0.1:3001/health || exit 1
|
||||||
CMD ["node","src/index.js"]
|
CMD ["node","src/index.js"]
|
||||||
|
|
||||||
|
|||||||
159
backend/README2.md
Normal file
159
backend/README2.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# DMS Backend – คู่มือ Auth & RBAC/ABAC
|
||||||
|
|
||||||
|
## ภาพรวมระบบ
|
||||||
|
Backend ใช้ **Bearer Token** สำหรับการยืนยันตัวตน และตรวจสอบสิทธิ์ด้วย **RBAC (Role-Based Access Control)** ร่วมกับ **ABAC (Attribute-Based Access Control)**
|
||||||
|
|
||||||
|
โครงหลักคือ:
|
||||||
|
1. **authJwt()** → ตรวจสอบ JWT ใน header `Authorization: Bearer ...`
|
||||||
|
2. **loadPrincipalMw()** → โหลดข้อมูลผู้ใช้ + บทบาท + สิทธิ์ + ขอบเขตโปรเจ็ค/องค์กร
|
||||||
|
3. **requirePerm()** → ตรวจสอบ `perm_code` จากตาราง `permissions` และบังคับ ABAC (ORG/PROJECT scope)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## การยืนยันตัวตน (Authentication)
|
||||||
|
|
||||||
|
### Frontend ส่งอย่างไร
|
||||||
|
```http
|
||||||
|
GET /api/projects
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **ไม่มีการใช้ cookie** (Bearer-only)
|
||||||
|
- ถ้า token หมดอายุ ให้ใช้ `refresh_token` ไปขอใหม่ที่ `/api/auth/refresh`
|
||||||
|
|
||||||
|
### Middleware `authJwt()`
|
||||||
|
- อ่าน `Authorization: Bearer ...`
|
||||||
|
- ตรวจสอบด้วย `JWT_SECRET`
|
||||||
|
- เติม `req.auth = { user_id, username }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## การโหลด Principal
|
||||||
|
|
||||||
|
### Middleware `loadPrincipalMw()`
|
||||||
|
- ใช้ `user_id` ไป query DB:
|
||||||
|
- users, roles, permissions, project_ids, org_ids
|
||||||
|
- สร้าง `req.principal`:
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
user_id, username, email, first_name, last_name, org_id,
|
||||||
|
roles: [{ role_id, role_code, role_name }],
|
||||||
|
permissions: Set<perm_code>,
|
||||||
|
project_ids: [..],
|
||||||
|
org_ids: [..],
|
||||||
|
is_superadmin: true/false,
|
||||||
|
|
||||||
|
// helper functions
|
||||||
|
can(code),
|
||||||
|
canAny(codes[]),
|
||||||
|
canAll(codes[]),
|
||||||
|
inProject(pid),
|
||||||
|
inOrg(oid)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## การตรวจสอบสิทธิ์ (RBAC + ABAC)
|
||||||
|
|
||||||
|
### Middleware `requirePerm(permCode, { projectParam?, orgParam? })`
|
||||||
|
1. ตรวจว่า user มี `permCode` หรือเป็น superadmin
|
||||||
|
2. อ่าน `scope_level` จากตาราง `permissions`
|
||||||
|
- `GLOBAL` → มีสิทธิ์ก็พอ
|
||||||
|
- `ORG` → ต้องมีสิทธิ์ + อยู่ใน org scope
|
||||||
|
- `PROJECT` → ต้องมีสิทธิ์ + อยู่ใน project scope
|
||||||
|
3. อ่าน `project_id` / `org_id` จาก request (`params`, `query`, `body`)
|
||||||
|
4. ถ้าไม่ผ่าน → คืน `403 FORBIDDEN`
|
||||||
|
|
||||||
|
### Error response ตัวอย่าง
|
||||||
|
```json
|
||||||
|
{ "error": "FORBIDDEN", "need": "projects.manage" }
|
||||||
|
{ "error": "FORBIDDEN_PROJECT", "project_id": 12 }
|
||||||
|
{ "error": "FORBIDDEN_ORG", "org_id": 5 }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## รูปแบบที่แนะนำ
|
||||||
|
|
||||||
|
### List (PROJECT scope)
|
||||||
|
```js
|
||||||
|
r.get("/", requirePerm("documents.view", { projectParam: "project_id" }), async (req, res) => {
|
||||||
|
const P = req.principal;
|
||||||
|
const { project_id } = req.query;
|
||||||
|
const cond = [], params = [];
|
||||||
|
|
||||||
|
if (!P.is_superadmin) {
|
||||||
|
if (project_id) {
|
||||||
|
if (!P.inProject(+project_id)) return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
cond.push("project_id=?"); params.push(+project_id);
|
||||||
|
} else if (P.project_ids?.length) {
|
||||||
|
cond.push(`project_id IN (${P.project_ids.map(()=>"?").join(",")})`);
|
||||||
|
params.push(...P.project_ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
|
||||||
|
const [rows] = await sql.query(`SELECT * FROM documents ${where} ORDER BY created_at DESC LIMIT 50`, params);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Item (PROJECT scope)
|
||||||
|
```js
|
||||||
|
r.delete("/:id", requirePerm("drawings.delete"), async (req, res) => {
|
||||||
|
const id = +req.params.id;
|
||||||
|
const [[row]] = await sql.query("SELECT project_id FROM drawings WHERE id=?", [id]);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
if (!req.principal.is_superadmin && !req.principal.inProject(row.project_id)) {
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
}
|
||||||
|
await sql.query("DELETE FROM drawings WHERE id=?", [id]);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## การแมปสิทธิ์ (perm_code)
|
||||||
|
|
||||||
|
| หมวด | สิทธิ์ (perm_code) | scope |
|
||||||
|
|--------------|---------------------------------------------|------------|
|
||||||
|
| Organizations| `organizations.view`, `organizations.manage`| GLOBAL |
|
||||||
|
| Projects | `projects.view`, `projects.manage` | ORG |
|
||||||
|
| Drawings | `drawings.view`, `drawings.upload`, `drawings.delete` | PROJECT |
|
||||||
|
| Documents | `documents.view`, `documents.manage` | PROJECT |
|
||||||
|
| RFAs | `rfas.view`, `rfas.create`, `rfas.respond`, `rfas.delete` | PROJECT |
|
||||||
|
| Correspondences | `corr.view`, `corr.manage` | PROJECT |
|
||||||
|
| Transmittals | `transmittals.manage` | PROJECT |
|
||||||
|
| Reports | `reports.view` | GLOBAL |
|
||||||
|
| Settings | `settings.manage` | GLOBAL |
|
||||||
|
| Admin | `admin.access` | ORG |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist สำหรับเพิ่ม Endpoint ใหม่
|
||||||
|
|
||||||
|
1. เลือก `perm_code` ที่ตรงกับ seed
|
||||||
|
2. ใส่ `requirePerm("<perm>", { projectParam?: "...", orgParam?: "..." })`
|
||||||
|
3. ถ้าเป็น GET/PUT/DELETE record เดี่ยว → ตรวจสอบซ้ำด้วย `inProject`/`inOrg`
|
||||||
|
4. ใช้ `callProc("sp_name", [...])` ถ้า endpoint เรียก Stored Procedure
|
||||||
|
5. ฝั่ง FE ต้องส่ง `Authorization: Bearer ...` และ parameter `project_id`/`org_id` ที่จำเป็น
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pitfalls ที่พบบ่อย
|
||||||
|
- ลืมส่ง `project_id` ในคำขอ → 403
|
||||||
|
- อ้าง perm_code ผิด (เช่น `document.view` แทน `documents.view`)
|
||||||
|
- ไม่กรอง project/org scope ใน query → ข้อมูลรั่ว
|
||||||
|
- ลืมเช็ค item-level ABAC → ข้ามขอบเขตได้
|
||||||
|
- ปน cookie-auth เข้ามา → backend จะไม่รองรับแล้ว
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
- ทุกคำขอ → Bearer Token
|
||||||
|
- `authJwt()` → ใส่ `req.auth`
|
||||||
|
- `loadPrincipalMw()` → ใส่ `req.principal` (roles, perms, scope)
|
||||||
|
- `requirePerm()` → บังคับ RBAC + ABAC อัตโนมัติ
|
||||||
|
- เพิ่ม endpoint ใหม่ → ใช้ checklist ข้างบน
|
||||||
77
backend/docker-compose.yml
Executable file
77
backend/docker-compose.yml
Executable file
@@ -0,0 +1,77 @@
|
|||||||
|
# File: backend/docker-compose.yml
|
||||||
|
# DMS Container v0_8_0 แยก service/ lcbp3-backend
|
||||||
|
x-restart: &restart_policy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
x-logging: &default_logging
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
volumes:
|
||||||
|
backend_node_modules:
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
<<: [*restart_policy, *default_logging]
|
||||||
|
image: dms-backend:dev
|
||||||
|
# pull_policy: never # <-- FINAL FIX ADDED HERE
|
||||||
|
container_name: dms_backend
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
#user: "node"
|
||||||
|
user: "1000:1000"
|
||||||
|
working_dir: /app
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: "2.0"
|
||||||
|
memory: 1G
|
||||||
|
reservations:
|
||||||
|
cpus: "0.25"
|
||||||
|
memory: 256M
|
||||||
|
environment:
|
||||||
|
TZ: "Asia/Bangkok"
|
||||||
|
CHOKIDAR_USEPOLLING: "1"
|
||||||
|
CHOKIDAR_INTERVAL: "300"
|
||||||
|
WATCHPACK_POLLING: "true"
|
||||||
|
# NODE_ENV: "production"
|
||||||
|
NODE_ENV: "development"
|
||||||
|
PORT: "3001"
|
||||||
|
DB_HOST: "mariadb"
|
||||||
|
DB_PORT: "3306"
|
||||||
|
DB_USER: "center"
|
||||||
|
DB_PASSWORD: "Center#2025"
|
||||||
|
DB_NAME: "dms"
|
||||||
|
JWT_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca"
|
||||||
|
JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca"
|
||||||
|
JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c"
|
||||||
|
ACCESS_TTL_MS: "900000"
|
||||||
|
REFRESH_TTL_MS: "604800000"
|
||||||
|
JWT_EXPIRES_IN: "12h"
|
||||||
|
PASSWORD_SALT_ROUNDS: "10"
|
||||||
|
FRONTEND_ORIGIN: "https://lcbp3.np-dms.work"
|
||||||
|
CORS_ORIGINS: "https://backend.np-dms.work,http://localhost:3000,http://127.0.0.1:3000,https://lcbp3.np-dms.work"
|
||||||
|
COOKIE_DOMAIN: ".np-dms.work"
|
||||||
|
RATE_LIMIT_WINDOW_MS: "900000"
|
||||||
|
RATE_LIMIT_MAX: "200"
|
||||||
|
BACKEND_LOG_DIR: "/app/logs"
|
||||||
|
networks:
|
||||||
|
lcbp3: {}
|
||||||
|
volumes:
|
||||||
|
- "/share/Container/dms/backend/src:/app/src:rw"
|
||||||
|
# - "/share/Container/dms/backend/package.json:/app/package.json"
|
||||||
|
# - "/share/Container/dms/backend/package-lock.json:/app/package-lock.json"
|
||||||
|
- "/share/dms-data:/share/dms-data:rw"
|
||||||
|
- "/share/Container/dms/logs/backend:/app/logs:rw"
|
||||||
|
# - "/share/Container/dms/backend/node_modules:/app/node_modules"
|
||||||
|
- "backend_node_modules:/app/node_modules"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3001/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
|
networks:
|
||||||
|
lcbp3:
|
||||||
|
external: true
|
||||||
6069
backend/package-lock.json
generated
Executable file → Normal file
6069
backend/package-lock.json
generated
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "dms-backend",
|
"name": "dms-backend",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon --watch src src/index.js",
|
"dev": "nodemon --watch src src/index.js",
|
||||||
"dev:desktop": "node --watch src/index.js",
|
"dev:desktop": "node --watch src/index.js",
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"lint": "echo 'lint placeholder'",
|
"lint": "eslint . --ext .js",
|
||||||
|
"lint:fix": "eslint . --ext .js --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:coverage": "jest --coverage",
|
||||||
|
"test:watch:coverage": "jest --watch --coverage",
|
||||||
"health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\"",
|
"health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\"",
|
||||||
"postinstall": "node -e \"console.log('Installed dms-backend %s','0.6.0')\""
|
"postinstall": "node -e \"console.log('Installed dms-backend %s','0.6.0')\""
|
||||||
},
|
},
|
||||||
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -35,6 +38,12 @@
|
|||||||
"winston": "^3.13.0"
|
"winston": "^3.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.10"
|
"nodemon": "^3.1.10",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"prettier": "^3.1.0",
|
||||||
|
"eslint-config-prettier": "^9.0.0",
|
||||||
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"supertest": "^6.3.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
|
|
||||||
{
|
|
||||||
"name": "dms-backend",
|
|
||||||
"version": "0.5.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "node --env-file=../.env src/index.js",
|
|
||||||
"start": "node src/index.js",
|
|
||||||
"health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\""
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"bcrypt": "5.1.1",
|
|
||||||
"cors": "2.8.5",
|
|
||||||
"dotenv": "16.4.5",
|
|
||||||
"express": "4.19.2",
|
|
||||||
"express-rate-limit": "7.4.0",
|
|
||||||
"helmet": "7.1.0",
|
|
||||||
"jsonwebtoken": "9.0.2",
|
|
||||||
"mariadb": "3.3.1",
|
|
||||||
"morgan": "1.10.0",
|
|
||||||
"sequelize": "6.37.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "dms-backend",
|
|
||||||
"version": "0.6.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"main": "src/index.js",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"dev": "nodemon --watch src src/index.js",
|
|
||||||
"start": "node src/index.js",
|
|
||||||
"lint": "echo 'lint placeholder'",
|
|
||||||
"health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\"",
|
|
||||||
"postinstall": "node -e \"console.log('Installed dms-backend %s','0.6.0')\""
|
|
||||||
},
|
|
||||||
|
|
||||||
"dependencies": {
|
|
||||||
"bcrypt": "5.1.1",
|
|
||||||
"bcryptjs": "^2.4.3",
|
|
||||||
"cookie-parser": "^1.4.7",
|
|
||||||
"cors": "2.8.5",
|
|
||||||
"dotenv": "16.4.5",
|
|
||||||
"express": "^4.21.2",
|
|
||||||
"express-rate-limit": "7.4.0",
|
|
||||||
"helmet": "7.1.0",
|
|
||||||
"jsonwebtoken": "9.0.2",
|
|
||||||
"mariadb": "3.3.1",
|
|
||||||
"morgan": "^1.10.1",
|
|
||||||
"multer": "^2.0.2",
|
|
||||||
"mysql2": "^3.11.0",
|
|
||||||
"sequelize": "6.37.3",
|
|
||||||
"winston": "^3.13.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"nodemon": "^3.1.10"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,45 @@
|
|||||||
export const config = {
|
// FILE: backend/src/config.js
|
||||||
PORT: Number(process.env.BACKEND_PORT || 3001),
|
// Centralized configuration (ESM)
|
||||||
DB: {
|
|
||||||
HOST: process.env.DB_HOST || 'mariadb',
|
const toInt = (v, d) => {
|
||||||
PORT: Number(process.env.DB_PORT || 3306),
|
const n = Number(v);
|
||||||
USER: process.env.DB_USER || 'center',
|
return Number.isFinite(n) ? n : d;
|
||||||
PASS: process.env.DB_PASSWORD || 'Center#2025',
|
|
||||||
NAME: process.env.DB_NAME || 'dms',
|
|
||||||
},
|
|
||||||
JWT: {
|
|
||||||
SECRET: process.env.JWT_SECRET || '8b0df02e4aee9f9f79a4f2d8ba77b0b82c1ee3446b68cb0bae94ab54d60f8d9e',
|
|
||||||
EXPIRES_IN: process.env.JWT_EXPIRES_IN || '8h',
|
|
||||||
REFRESH_SECRET: process.env.JWT_REFRESH_SECRET || '31r2Wp59E8JukXjYiMopzoHDxJBLVefjR9YOOHjk62R5PbFVXzbx2Wjyc9weVDgK',
|
|
||||||
REFRESH_EXPIRES_IN: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
|
||||||
},
|
|
||||||
SECURITY: {
|
|
||||||
RATE_LIMIT_WINDOW_MS: Number(process.env.RATE_LIMIT_WINDOW_MS || 60_000),
|
|
||||||
RATE_LIMIT_MAX: Number(process.env.RATE_LIMIT_MAX || 100),
|
|
||||||
},
|
|
||||||
CORS_ORIGINS: (process.env.CORS_ALLOWLIST || '')
|
|
||||||
.split(',')
|
|
||||||
.map(s => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
};
|
};
|
||||||
|
const parseAllowlist = (s) =>
|
||||||
|
String(s || "")
|
||||||
|
.split(",")
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
PORT: toInt(process.env.PORT ?? process.env.BACKEND_PORT, 3001),
|
||||||
|
|
||||||
|
DB: {
|
||||||
|
HOST: process.env.DB_HOST || "mariadb",
|
||||||
|
PORT: toInt(process.env.DB_PORT, 3306),
|
||||||
|
USER: process.env.DB_USER || "center",
|
||||||
|
PASS: process.env.DB_PASSWORD || "Center#2025",
|
||||||
|
NAME: process.env.DB_NAME || "dms",
|
||||||
|
},
|
||||||
|
|
||||||
|
JWT: {
|
||||||
|
SECRET: process.env.JWT_SECRET || "dev-secret",
|
||||||
|
EXPIRES_IN: process.env.JWT_EXPIRES_IN || "8h",
|
||||||
|
REFRESH_SECRET: process.env.JWT_REFRESH_SECRET || "dev-refresh",
|
||||||
|
REFRESH_EXPIRES_IN: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
|
||||||
|
},
|
||||||
|
|
||||||
|
SECURITY: {
|
||||||
|
RATE_LIMIT_WINDOW_MS: toInt(process.env.RATE_LIMIT_WINDOW_MS, 60_000),
|
||||||
|
RATE_LIMIT_MAX: toInt(process.env.RATE_LIMIT_MAX, 100),
|
||||||
|
},
|
||||||
|
|
||||||
|
CORS_ORIGINS: parseAllowlist(
|
||||||
|
process.env.CORS_ALLOWLIST ||
|
||||||
|
// เผื่อ dev ทั่วไป
|
||||||
|
"http://localhost:3000,http://127.0.0.1:3000"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// เผื่อไฟล์ไหน import แบบ default
|
||||||
|
export default config;
|
||||||
|
|||||||
@@ -1,45 +1,60 @@
|
|||||||
// =============================================================
|
|
||||||
// FILE: src/config/permissions.js
|
// FILE: src/config/permissions.js
|
||||||
// Purpose: Map permission_code to your seed naming convention.
|
// Reference enum สำหรับฝั่งโค้ด/ฟรอนต์เท่านั้น
|
||||||
// - Choose one PROFILE (by SEED_PROFILE env) or edit inline to match exactly
|
// แหล่งจริงของ scope อ่านจาก DB: permissions.scope_level (ผ่าน requirePerm())
|
||||||
// what's in 06_dms_v0_5_0_data_v5_1_seed_users.sql
|
|
||||||
// =============================================================
|
|
||||||
|
|
||||||
|
const PERM = {
|
||||||
const V5_DOT = {
|
organizations: {
|
||||||
organization: { read: 'organization.read' },
|
view: "organizations.view",
|
||||||
project: { read: 'project.read', create: 'project.create', update: 'project.update', delete: 'project.delete' },
|
manage: "organizations.manage",
|
||||||
correspondence: { read: 'correspondence.read', create: 'correspondence.create', update: 'correspondence.update', delete: 'correspondence.delete', upload: 'correspondence.upload' },
|
},
|
||||||
rfa: { read: 'rfa.read', create: 'rfa.create', update: 'rfa.update', delete: 'rfa.delete', upload: 'rfa.upload' },
|
projects: {
|
||||||
drawing: { read: 'drawing.read', create: 'drawing.create', update: 'drawing.update', delete: 'drawing.delete', upload: 'drawing.upload' },
|
view: "projects.view",
|
||||||
transmittal: { read: 'transmittal.read', create: 'transmittal.create', update: 'transmittal.update', delete: 'transmittal.delete', upload: 'transmittal.upload' },
|
manage: "projects.manage",
|
||||||
contract: { read: 'contract.read', create: 'contract.create', update: 'contract.update', delete: 'contract.delete' },
|
partiesManage: "project_parties.manage",
|
||||||
contract_dwg: { read: 'contract_dwg.read', create: 'contract_dwg.create', update: 'contract_dwg.update', delete: 'contract_dwg.delete' },
|
},
|
||||||
category: { read: 'category.read', create: 'category.create', update: 'category.update', delete: 'category.delete' },
|
drawings: {
|
||||||
volume: { read: 'volume.read', create: 'volume.create', update: 'volume.update', delete: 'volume.delete' },
|
view: "drawings.view",
|
||||||
permission: { read: 'permission.read' },
|
upload: "drawings.upload",
|
||||||
user: { read: 'user.read' },
|
delete: "drawings.delete",
|
||||||
|
},
|
||||||
|
documents: {
|
||||||
|
view: "documents.view",
|
||||||
|
manage: "documents.manage",
|
||||||
|
},
|
||||||
|
materials: {
|
||||||
|
view: "materials.view",
|
||||||
|
manage: "materials.manage",
|
||||||
|
},
|
||||||
|
ms: {
|
||||||
|
view: "ms.view",
|
||||||
|
manage: "ms.manage",
|
||||||
|
},
|
||||||
|
rfas: {
|
||||||
|
view: "rfas.view",
|
||||||
|
create: "rfas.create",
|
||||||
|
respond: "rfas.respond",
|
||||||
|
delete: "rfas.delete",
|
||||||
|
},
|
||||||
|
correspondences: {
|
||||||
|
view: "corr.view",
|
||||||
|
manage: "corr.manage",
|
||||||
|
},
|
||||||
|
transmittals: {
|
||||||
|
manage: "transmittals.manage",
|
||||||
|
},
|
||||||
|
circulations: {
|
||||||
|
manage: "cirs.manage",
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
access: "admin.access",
|
||||||
|
},
|
||||||
|
reports: {
|
||||||
|
view: "reports.view",
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
manage: "settings.manage",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { PERM };
|
||||||
const V5_SNAKE = {
|
|
||||||
organization: { read: 'organization_read' },
|
|
||||||
project: { read: 'project_read', create: 'project_create', update: 'project_update', delete: 'project_delete' },
|
|
||||||
correspondence: { read: 'correspondence_read', create: 'correspondence_create', update: 'correspondence_update', delete: 'correspondence_delete', upload: 'correspondence_upload' },
|
|
||||||
rfa: { read: 'rfa_read', create: 'rfa_create', update: 'rfa_update', delete: 'rfa_delete', upload: 'rfa_upload' },
|
|
||||||
drawing: { read: 'drawing_read', create: 'drawing_create', update: 'drawing_update', delete: 'drawing_delete', upload: 'drawing_upload' },
|
|
||||||
transmittal: { read: 'transmittal_read', create: 'transmittal_create', update: 'transmittal_update', delete: 'transmittal_delete', upload: 'transmittal_upload' },
|
|
||||||
contract: { read: 'contract_read', create: 'contract_create', update: 'contract_update', delete: 'contract_delete' },
|
|
||||||
contract_dwg: { read: 'contract_dwg_read', create: 'contract_dwg_create', update: 'contract_dwg_update', delete: 'contract_dwg_delete' },
|
|
||||||
category: { read: 'category_read', create: 'category_create', update: 'category_update', delete: 'category_delete' },
|
|
||||||
volume: { read: 'volume_read', create: 'volume_create', update: 'volume_update', delete: 'volume_delete' },
|
|
||||||
permission: { read: 'permission_read' },
|
|
||||||
user: { read: 'user_read' },
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const PROFILE = (process.env.SEED_PROFILE || 'V5_DOT').toUpperCase();
|
|
||||||
|
|
||||||
|
|
||||||
export const PERM = PROFILE === 'V5_SNAKE' ? V5_SNAKE : V5_DOT;
|
|
||||||
export default PERM;
|
export default PERM;
|
||||||
39
backend/src/db/index copy.js
Normal file
39
backend/src/db/index copy.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// FILE: backend/src/db/index.js (ESM)
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
|
||||||
|
const {
|
||||||
|
DB_HOST = "mariadb",
|
||||||
|
DB_PORT = "3306",
|
||||||
|
DB_USER = "center",
|
||||||
|
DB_PASSWORD = "Center#2025",
|
||||||
|
DB_NAME = "dms",
|
||||||
|
DB_CONN_LIMIT = "10",
|
||||||
|
} = process.env;
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: DB_HOST,
|
||||||
|
port: Number(DB_PORT),
|
||||||
|
user: DB_USER,
|
||||||
|
password: DB_PASSWORD,
|
||||||
|
database: DB_NAME,
|
||||||
|
connectionLimit: Number(DB_CONN_LIMIT),
|
||||||
|
waitForConnections: true,
|
||||||
|
namedPlaceholders: true,
|
||||||
|
dateStrings: true, // คงวันที่เป็น string
|
||||||
|
timezone: "Z", // ใช้ UTC
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* เรียก Stored Procedure แบบง่าย
|
||||||
|
* @param {string} procName ชื่อโปรซีเยอร์ เช่น "sp_rfa_create_with_items"
|
||||||
|
* @param {Array<any>} params ลำดับพารามิเตอร์
|
||||||
|
* @returns {Promise<any>} rows จาก CALL
|
||||||
|
*/
|
||||||
|
export async function callProc(procName, params = []) {
|
||||||
|
const placeholders = params.map(() => "?").join(",");
|
||||||
|
const sql = `CALL ${procName}(${placeholders})`;
|
||||||
|
const [rows] = await pool.query(sql, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default pool; // ใช้ sql.query(...) ได้ตามที่ routes เรียกอยู่
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
// ESM
|
// FILE: backend/src/db/index.js (ESM)
|
||||||
import mysql from 'mysql2/promise';
|
import mysql from "mysql2/promise";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
DB_HOST = 'mariadb',
|
DB_HOST = "mariadb",
|
||||||
DB_PORT = '3306',
|
DB_PORT = "3306",
|
||||||
DB_USER = 'center',
|
DB_USER = "center",
|
||||||
DB_PASSWORD = 'Center#2025',
|
DB_PASSWORD = "Center#2025",
|
||||||
DB_NAME = 'dms',
|
DB_NAME = "dms",
|
||||||
DB_CONN_LIMIT = '10',
|
DB_CONN_LIMIT = "10",
|
||||||
} = process.env;
|
} = process.env;
|
||||||
|
|
||||||
const pool = mysql.createPool({
|
const pool = mysql.createPool({
|
||||||
@@ -17,21 +17,23 @@ const pool = mysql.createPool({
|
|||||||
password: DB_PASSWORD,
|
password: DB_PASSWORD,
|
||||||
database: DB_NAME,
|
database: DB_NAME,
|
||||||
connectionLimit: Number(DB_CONN_LIMIT),
|
connectionLimit: Number(DB_CONN_LIMIT),
|
||||||
waitForConnections: true, // Recommended for handling connection spikes
|
waitForConnections: true,
|
||||||
namedPlaceholders: true,
|
namedPlaceholders: true,
|
||||||
dateStrings: true, // Keep dates as strings
|
dateStrings: true, // คงวันที่เป็น string
|
||||||
timezone: 'Z', // Store and retrieve dates in UTC
|
timezone: "Z", // ใช้ UTC
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes a SQL query with parameters.
|
* เรียก Stored Procedure แบบง่าย
|
||||||
* @param {string} sql The SQL query string.
|
* @param {string} procName ชื่อโปรซีเยอร์ เช่น "sp_rfa_create_with_items"
|
||||||
* @param {object} [params={}] The parameters to bind to the query.
|
* @param {Array<any>} params ลำดับพารามิเตอร์
|
||||||
* @returns {Promise<any[]>} A promise that resolves to an array of rows.
|
* @returns {Promise<any>} rows จาก CALL
|
||||||
*/
|
*/
|
||||||
export async function query(sql, params = {}) {
|
export async function callProc(procName, params = []) {
|
||||||
const [rows] = await pool.execute(sql, params);
|
const placeholders = params.map(() => "?").join(",");
|
||||||
|
const sql = `CALL ${procName}(${placeholders})`;
|
||||||
|
const [rows] = await pool.query(sql, params);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default pool;
|
export default pool; // ใช้ sql.query(...) ได้ตามที่ routes เรียกอยู่
|
||||||
|
|||||||
71
backend/src/db/sequelize copy.js
Normal file
71
backend/src/db/sequelize copy.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// FILE: backend/src/db/sequelize.js
|
||||||
|
// “lazy-load” ตาม env ปลอดภัยกว่า และยังคง dbReady() ให้เรียกทดสอบได้
|
||||||
|
// ใช้ได้เมื่อจำเป็น (เช่น งาน admin tool เฉพาะกิจ)
|
||||||
|
// ตั้ง ENABLE_SEQUELIZE=1 เพื่อเปิดใช้ Model loader; ไม่งั้นจะเป็นโหมดเบา ๆ
|
||||||
|
import { Sequelize } from "sequelize";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
|
||||||
|
export const sequelize = new Sequelize(
|
||||||
|
config.DB.NAME,
|
||||||
|
config.DB.USER,
|
||||||
|
config.DB.PASS,
|
||||||
|
{
|
||||||
|
host: config.DB.HOST,
|
||||||
|
port: config.DB.PORT,
|
||||||
|
dialect: "mariadb",
|
||||||
|
logging: false,
|
||||||
|
dialectOptions: { timezone: "Z" },
|
||||||
|
define: { freezeTableName: true, underscored: false, timestamps: false },
|
||||||
|
pool: { max: 10, min: 0, idle: 10000 },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export let User = null;
|
||||||
|
export let Role = null;
|
||||||
|
export let Permission = null;
|
||||||
|
export let UserRole = null;
|
||||||
|
export let RolePermission = null;
|
||||||
|
|
||||||
|
if (process.env.ENABLE_SEQUELIZE === "1") {
|
||||||
|
// โหลดโมเดลแบบ on-demand เพื่อลดความเสี่ยง runtime หากไฟล์โมเดลไม่มี
|
||||||
|
const mdlUser = await import("./models/User.js").catch(() => null);
|
||||||
|
const mdlRole = await import("./models/Role.js").catch(() => null);
|
||||||
|
const mdlPerm = await import("./models/Permission.js").catch(() => null);
|
||||||
|
const mdlUR = await import("./models/UserRole.js").catch(() => null);
|
||||||
|
const mdlRP = await import("./models/RolePermission.js").catch(() => null);
|
||||||
|
|
||||||
|
if (mdlUser?.default) User = mdlUser.default(sequelize);
|
||||||
|
if (mdlRole?.default) Role = mdlRole.default(sequelize);
|
||||||
|
if (mdlPerm?.default) Permission = mdlPerm.default(sequelize);
|
||||||
|
if (mdlUR?.default) UserRole = mdlUR.default(sequelize);
|
||||||
|
if (mdlRP?.default) RolePermission = mdlRP.default(sequelize);
|
||||||
|
|
||||||
|
if (User && Role && Permission && UserRole && RolePermission) {
|
||||||
|
User.belongsToMany(Role, {
|
||||||
|
through: UserRole,
|
||||||
|
foreignKey: "user_id",
|
||||||
|
otherKey: "role_id",
|
||||||
|
});
|
||||||
|
Role.belongsToMany(User, {
|
||||||
|
through: UserRole,
|
||||||
|
foreignKey: "role_id",
|
||||||
|
otherKey: "user_id",
|
||||||
|
});
|
||||||
|
|
||||||
|
Role.belongsToMany(Permission, {
|
||||||
|
through: RolePermission,
|
||||||
|
foreignKey: "role_id",
|
||||||
|
otherKey: "permission_id",
|
||||||
|
});
|
||||||
|
Permission.belongsToMany(Role, {
|
||||||
|
through: RolePermission,
|
||||||
|
foreignKey: "permission_id",
|
||||||
|
otherKey: "role_id",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dbReady() {
|
||||||
|
// โหมดเบา ๆ: แค่ทดสอบเชื่อมต่อ
|
||||||
|
await sequelize.authenticate();
|
||||||
|
}
|
||||||
74
backend/src/db/sequelize.js
Normal file → Executable file
74
backend/src/db/sequelize.js
Normal file → Executable file
@@ -1,5 +1,9 @@
|
|||||||
import { Sequelize } from 'sequelize';
|
// FILE: backend/src/db/sequelize.js
|
||||||
import { config } from '../config.js';
|
// “lazy-load” ตาม env ปลอดภัยกว่า และยังคง dbReady() ให้เรียกทดสอบได้
|
||||||
|
// ใช้ได้เมื่อจำเป็น (เช่น งาน admin tool เฉพาะกิจ)
|
||||||
|
// ตั้ง ENABLE_SEQUELIZE=1 เพื่อเปิดใช้ Model loader; ไม่งั้นจะเป็นโหมดเบา ๆ
|
||||||
|
import { Sequelize } from "sequelize";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
|
||||||
export const sequelize = new Sequelize(
|
export const sequelize = new Sequelize(
|
||||||
config.DB.NAME,
|
config.DB.NAME,
|
||||||
@@ -8,36 +12,60 @@ export const sequelize = new Sequelize(
|
|||||||
{
|
{
|
||||||
host: config.DB.HOST,
|
host: config.DB.HOST,
|
||||||
port: config.DB.PORT,
|
port: config.DB.PORT,
|
||||||
dialect: 'mariadb',
|
dialect: "mariadb",
|
||||||
logging: false,
|
logging: false,
|
||||||
dialectOptions: { timezone: 'Z' },
|
dialectOptions: { timezone: "Z" },
|
||||||
define: {
|
define: { freezeTableName: true, underscored: false, timestamps: false },
|
||||||
freezeTableName: true,
|
|
||||||
underscored: false,
|
|
||||||
timestamps: false,
|
|
||||||
},
|
|
||||||
pool: { max: 10, min: 0, idle: 10000 },
|
pool: { max: 10, min: 0, idle: 10000 },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
import UserModel from './models/User.js';
|
export let User = null;
|
||||||
import RoleModel from './models/Role.js';
|
export let Role = null;
|
||||||
import PermissionModel from './models/Permission.js';
|
export let Permission = null;
|
||||||
import UserRoleModel from './models/UserRole.js';
|
export let UserRole = null;
|
||||||
import RolePermissionModel from './models/RolePermission.js';
|
export let RolePermission = null;
|
||||||
|
|
||||||
export const User = UserModel(sequelize);
|
if (process.env.ENABLE_SEQUELIZE === "1") {
|
||||||
export const Role = RoleModel(sequelize);
|
// โหลดโมเดลแบบ on-demand เพื่อลดความเสี่ยง runtime หากไฟล์โมเดลไม่มี
|
||||||
export const Permission = PermissionModel(sequelize);
|
const mdlUser = await import("./models/User.js").catch(() => null);
|
||||||
export const UserRole = UserRoleModel(sequelize);
|
const mdlRole = await import("./models/Role.js").catch(() => null);
|
||||||
export const RolePermission = RolePermissionModel(sequelize);
|
const mdlPerm = await import("./models/Permission.js").catch(() => null);
|
||||||
|
const mdlUR = await import("./models/UserRole.js").catch(() => null);
|
||||||
|
const mdlRP = await import("./models/RolePermission.js").catch(() => null);
|
||||||
|
|
||||||
User.belongsToMany(Role, { through: UserRole, foreignKey: 'user_id', otherKey: 'role_id' });
|
if (mdlUser?.default) User = mdlUser.default(sequelize);
|
||||||
Role.belongsToMany(User, { through: UserRole, foreignKey: 'role_id', otherKey: 'user_id' });
|
if (mdlRole?.default) Role = mdlRole.default(sequelize);
|
||||||
|
if (mdlPerm?.default) Permission = mdlPerm.default(sequelize);
|
||||||
|
if (mdlUR?.default) UserRole = mdlUR.default(sequelize);
|
||||||
|
if (mdlRP?.default) RolePermission = mdlRP.default(sequelize);
|
||||||
|
|
||||||
Role.belongsToMany(Permission, { through: RolePermission, foreignKey: 'role_id', otherKey: 'permission_id' });
|
if (User && Role && Permission && UserRole && RolePermission) {
|
||||||
Permission.belongsToMany(Role, { through: RolePermission, foreignKey: 'permission_id', otherKey: 'role_id' });
|
User.belongsToMany(Role, {
|
||||||
|
through: UserRole,
|
||||||
|
foreignKey: "user_id",
|
||||||
|
otherKey: "role_id",
|
||||||
|
});
|
||||||
|
Role.belongsToMany(User, {
|
||||||
|
through: UserRole,
|
||||||
|
foreignKey: "role_id",
|
||||||
|
otherKey: "user_id",
|
||||||
|
});
|
||||||
|
|
||||||
|
Role.belongsToMany(Permission, {
|
||||||
|
through: RolePermission,
|
||||||
|
foreignKey: "role_id",
|
||||||
|
otherKey: "permission_id",
|
||||||
|
});
|
||||||
|
Permission.belongsToMany(Role, {
|
||||||
|
through: RolePermission,
|
||||||
|
foreignKey: "permission_id",
|
||||||
|
otherKey: "role_id",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function dbReady() {
|
export async function dbReady() {
|
||||||
|
// โหมดเบา ๆ: แค่ทดสอบเชื่อมต่อ
|
||||||
await sequelize.authenticate();
|
await sequelize.authenticate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,200 +1,173 @@
|
|||||||
// src/index.js (ESM)
|
// FILE: backend/src/index.js (ESM) ไฟล์ฉบับ “Bearer-only”
|
||||||
// -------------------
|
// FILE: src/index.js (ESM)
|
||||||
// Node >= 18, Express 4/5 compatible
|
import fs from "node:fs";
|
||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
|
||||||
import fs from 'node:fs';
|
import sql from "./db/index.js";
|
||||||
import path from 'node:path';
|
import healthRouter from "./routes/health.js";
|
||||||
import express from 'express';
|
import { authJwt } from "./middleware/authJwt.js";
|
||||||
import cookieParser from 'cookie-parser';
|
import { loadPrincipalMw } from "./middleware/loadPrincipal.js";
|
||||||
import cors from 'cors';
|
|
||||||
|
|
||||||
import sql from './db/index.js';
|
|
||||||
import healthRouter from './routes/health.js';
|
|
||||||
import { authJwt } from './middleware/authJwt.js';
|
|
||||||
import { loadPrincipalMw } from './middleware/loadPrincipal.js';
|
|
||||||
|
|
||||||
// ROUTES
|
// ROUTES
|
||||||
import authRoutes from './routes/auth.js';
|
import authRoutes from "./routes/auth.js";
|
||||||
import lookupRoutes from './routes/lookup.js';
|
import lookupRoutes from "./routes/lookup.js";
|
||||||
import organizationsRoutes from './routes/organizations.js';
|
import organizationsRoutes from "./routes/organizations.js";
|
||||||
import projectsRoutes from './routes/projects.js';
|
import projectsRoutes from "./routes/projects.js";
|
||||||
import correspondencesRoutes from './routes/correspondences.js';
|
import correspondencesRoutes from "./routes/correspondences.js";
|
||||||
import rfasRoutes from './routes/rfas.js';
|
import rfasRoutes from "./routes/rfas.js";
|
||||||
import drawingsRoutes from './routes/drawings.js';
|
import drawingsRoutes from "./routes/drawings.js";
|
||||||
import transmittalsRoutes from './routes/transmittals.js';
|
import transmittalsRoutes from "./routes/transmittals.js";
|
||||||
import contractsRoutes from './routes/contracts.js';
|
import contractsRoutes from "./routes/contracts.js";
|
||||||
import contractDwgRoutes from './routes/contract_dwg.js';
|
import contractDwgRoutes from "./routes/contract_dwg.js";
|
||||||
import categoriesRoutes from './routes/categories.js';
|
import categoriesRoutes from "./routes/categories.js";
|
||||||
import volumesRoutes from './routes/volumes.js';
|
import volumesRoutes from "./routes/volumes.js";
|
||||||
import uploadsRoutes from './routes/uploads.js';
|
import uploadsRoutes from "./routes/uploads.js";
|
||||||
import usersRoutes from './routes/users.js';
|
import usersRoutes from "./routes/users.js";
|
||||||
import permissionsRoutes from './routes/permissions.js';
|
import permissionsRoutes from "./routes/permissions.js";
|
||||||
|
|
||||||
// import { requireAuth } from './middleware/requireAuth.js';
|
|
||||||
|
|
||||||
/* ==========================
|
|
||||||
* CONFIG (ปรับค่านี้ได้)
|
|
||||||
* ========================== */
|
|
||||||
// const PORT = Number(process.env.PORT || 7001);
|
|
||||||
const PORT = Number(process.env.PORT || 3001);
|
const PORT = Number(process.env.PORT || 3001);
|
||||||
const NODE_ENV = process.env.NODE_ENV || 'production';
|
const NODE_ENV = process.env.NODE_ENV || "development";
|
||||||
|
|
||||||
|
const FRONTEND_ORIGIN =
|
||||||
|
process.env.FRONTEND_ORIGIN || "https://lcbp3.np-dms.work";
|
||||||
|
|
||||||
// Origin ของ Frontend (ถ้ามี Nginx ด้านหน้า ให้ใช้โดเมน/พอร์ตของ Frontend)
|
|
||||||
// Origin ของ Frontend (ตั้งผ่าน ENV ในแต่ละสภาพแวดล้อม; dev ใช้ localhost)
|
|
||||||
const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'https://lcbp3.mycloudnas.com';
|
|
||||||
const ALLOW_ORIGINS = [
|
const ALLOW_ORIGINS = [
|
||||||
'http://localhost:3000',
|
"http://localhost:3000",
|
||||||
'http://127.0.0.1:3000',
|
"http://127.0.0.1:3000",
|
||||||
FRONTEND_ORIGIN,
|
FRONTEND_ORIGIN,
|
||||||
|
...(process.env.CORS_ALLOWLIST
|
||||||
|
? process.env.CORS_ALLOWLIST.split(",").map((x) => x.trim()).filter(Boolean)
|
||||||
|
: []),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
// ที่เก็บ log ภายใน container ถูก bind ไปที่ /share/Container/dms/logs/backend
|
const LOG_DIR = process.env.BACKEND_LOG_DIR || "/app/logs";
|
||||||
const LOG_DIR = process.env.BACKEND_LOG_DIR || '/app/logs';
|
|
||||||
|
|
||||||
// สร้างโฟลเดอร์ log ถ้ายังไม่มี (แก้ปัญหา Permission denied ล่วงหน้า: ให้ host map เป็น 775 และ uid=100)
|
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
|
if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[WARN] Cannot ensure LOG_DIR:', LOG_DIR, e?.message);
|
console.warn("[WARN] Cannot ensure LOG_DIR:", LOG_DIR, e?.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================
|
|
||||||
* APP INIT
|
|
||||||
* ========================== */
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set("trust proxy", 1);
|
||||||
|
|
||||||
// CORS แบบกำหนด origin ตามรายการที่อนุญาต + อนุญาต credentials
|
// CORS: allow list
|
||||||
app.use(cors({
|
app.use(
|
||||||
origin(origin, cb) {
|
cors({
|
||||||
// อนุญาต server-to-server / curl ที่ไม่มี Origin
|
origin(origin, cb) {
|
||||||
if (!origin) return cb(null, true);
|
if (!origin) return cb(null, true); // server-to-server / curl
|
||||||
return cb(null, ALLOW_ORIGINS.includes(origin));
|
cb(null, ALLOW_ORIGINS.includes(origin));
|
||||||
},
|
},
|
||||||
credentials: true,
|
credentials: false, // Bearer-only
|
||||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
allowedHeaders: [
|
||||||
exposedHeaders: ['Content-Disposition', 'Content-Length'],
|
"Content-Type",
|
||||||
}));
|
"Authorization",
|
||||||
// จัดการ preflight ให้ครบ
|
"X-Requested-With",
|
||||||
app.options('*', cors({
|
"Accept",
|
||||||
origin(origin, cb) {
|
"Origin",
|
||||||
if (!origin) return cb(null, true);
|
"Referer",
|
||||||
return cb(null, ALLOW_ORIGINS.includes(origin));
|
"User-Agent",
|
||||||
},
|
"Cache-Control",
|
||||||
credentials: true,
|
"Pragma",
|
||||||
}));
|
],
|
||||||
|
exposedHeaders: ["Content-Disposition", "Content-Length"],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.options(
|
||||||
|
"*",
|
||||||
|
cors({
|
||||||
|
origin(origin, cb) {
|
||||||
|
if (!origin) return cb(null, true);
|
||||||
|
cb(null, ALLOW_ORIGINS.includes(origin));
|
||||||
|
},
|
||||||
|
credentials: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
app.use(cookieParser());
|
app.use(express.json({ limit: "10mb" }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||||
|
|
||||||
// Payload limits
|
// minimal access log
|
||||||
app.use(express.json({ limit: '10mb' }));
|
|
||||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
|
||||||
|
|
||||||
// Access log (ขั้นต่ำ): พิมพ์ลง stdout ให้ Docker เก็บ; ถ้าต้องการเขียนไฟล์ ให้เปลี่ยนเป็น fs.appendFileSync
|
|
||||||
app.use((req, _res, next) => {
|
app.use((req, _res, next) => {
|
||||||
console.log(`[REQ] ${req.method} ${req.originalUrl}`);
|
console.log(`[REQ] ${req.method} ${req.originalUrl}`);
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ==========================
|
// health/info (เปิดทั้ง /health, /livez, /readyz, /info)
|
||||||
* HEALTH / READY / INFO
|
app.get("/health", async (_req, res) => {
|
||||||
* ========================== */
|
|
||||||
app.get('/health', async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const [[{ now }]] = await sql.query('SELECT NOW() AS now');
|
const [[{ now }]] = await sql.query("SELECT NOW() AS now");
|
||||||
return res.json({ status: 'ok', db: 'ok', now });
|
res.json({ status: "ok", db: "ok", now });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return res.status(500).json({ status: 'degraded', db: 'fail', error: e?.message });
|
res.status(500).json({ status: "degraded", db: "fail", error: e?.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
app.get("/livez", (_req, res) => res.send("ok"));
|
||||||
// Kubernetes-style endpoints (ถ้าใช้)
|
app.get("/readyz", async (_req, res) => {
|
||||||
app.get('/livez', (req, res) => res.send('ok'));
|
try { await sql.query("SELECT 1"); res.send("ready"); }
|
||||||
app.get('/readyz', async (req, res) => {
|
catch { res.status(500).send("not-ready"); }
|
||||||
try {
|
|
||||||
await sql.query('SELECT 1');
|
|
||||||
res.send('ready');
|
|
||||||
} catch {
|
|
||||||
res.status(500).send('not-ready');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
app.get("/info", (_req, res) =>
|
||||||
// เวอร์ชัน/บิลด์ (เติมจาก ENV ถ้าต้องการ)
|
|
||||||
app.get('/info', (req, res) => {
|
|
||||||
res.json({
|
res.json({
|
||||||
name: 'dms-backend',
|
name: "dms-backend",
|
||||||
env: NODE_ENV,
|
env: NODE_ENV,
|
||||||
version: process.env.APP_VERSION || '0.5.0',
|
version: process.env.APP_VERSION || "0.5.0",
|
||||||
commit: process.env.GIT_COMMIT || undefined,
|
commit: process.env.GIT_COMMIT || undefined,
|
||||||
});
|
})
|
||||||
});
|
);
|
||||||
|
|
||||||
/* ==========================
|
// ---------- Public (no auth) ----------
|
||||||
* PROTECTED API
|
app.use("/api", healthRouter);
|
||||||
* ========================== */
|
app.use("/api/auth", authRoutes);
|
||||||
// ต้อง auth + principal ก่อนเข้าทุก /api/*
|
|
||||||
app.use('/api', healthRouter);
|
|
||||||
app.use('/api/auth', authRoutes); // login/refresh/logout (ไม่ต้องผ่าน authJwt ทั้งกลุ่ม)
|
|
||||||
app.use('/api', authJwt(), loadPrincipalMw()); // จากนี้ต้องมี JWT + principal
|
|
||||||
|
|
||||||
app.use('/api/lookup', lookupRoutes);
|
// ---------- Protected (Bearer + Principal) ----------
|
||||||
// โมดูลหลัก
|
app.use("/api", authJwt(), loadPrincipalMw());
|
||||||
app.use('/api/organizations', organizationsRoutes);
|
|
||||||
app.use('/api/projects', projectsRoutes);
|
|
||||||
app.use('/api/correspondences', correspondencesRoutes);
|
|
||||||
app.use('/api/rfas', rfasRoutes);
|
|
||||||
app.use('/api/drawings', drawingsRoutes);
|
|
||||||
app.use('/api/transmittals', transmittalsRoutes);
|
|
||||||
app.use('/api/contracts', contractsRoutes);
|
|
||||||
app.use('/api/contract-dwg', contractDwgRoutes);
|
|
||||||
app.use('/api/categories', categoriesRoutes);
|
|
||||||
app.use('/api/volumes', volumesRoutes);
|
|
||||||
app.use('/api/uploads', uploadsRoutes);
|
|
||||||
app.use('/api/users', usersRoutes);
|
|
||||||
app.use('/api/permissions', permissionsRoutes);
|
|
||||||
|
|
||||||
/* ==========================
|
app.use("/api/lookup", lookupRoutes);
|
||||||
* NOT FOUND & ERROR HANDLERS
|
app.use("/api/organizations", organizationsRoutes);
|
||||||
* ========================== */
|
app.use("/api/projects", projectsRoutes);
|
||||||
app.use((req, res) => {
|
app.use("/api/correspondences", correspondencesRoutes);
|
||||||
res.status(404).json({ error: 'NOT_FOUND', path: req.originalUrl });
|
app.use("/api/rfas", rfasRoutes);
|
||||||
});
|
app.use("/api/drawings", drawingsRoutes);
|
||||||
|
app.use("/api/transmittals", transmittalsRoutes);
|
||||||
|
app.use("/api/contracts", contractsRoutes);
|
||||||
|
app.use("/api/contract-dwg", contractDwgRoutes);
|
||||||
|
app.use("/api/categories", categoriesRoutes);
|
||||||
|
app.use("/api/volumes", volumesRoutes);
|
||||||
|
app.use("/api/uploads", uploadsRoutes);
|
||||||
|
app.use("/api/users", usersRoutes);
|
||||||
|
app.use("/api/permissions", permissionsRoutes);
|
||||||
|
|
||||||
// ต้องมี 4 พารามิเตอร์เพื่อเป็น error handler ใน Express
|
// 404 / error
|
||||||
|
app.use((req, res) =>
|
||||||
|
res.status(404).json({ error: "NOT_FOUND", path: req.originalUrl })
|
||||||
|
);
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
app.use((err, req, res, _next) => {
|
app.use((err, _req, res, _next) => {
|
||||||
console.error('[UNHANDLED ERROR]', err);
|
console.error("[UNHANDLED ERROR]", err);
|
||||||
const status = err?.status || 500;
|
res.status(err?.status || 500).json({ error: "SERVER_ERROR" });
|
||||||
res.status(status).json({
|
|
||||||
error: 'SERVER_ERROR',
|
|
||||||
message: NODE_ENV === 'production' ? undefined : err?.message,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ==========================
|
// START
|
||||||
* START SERVER
|
|
||||||
* ========================== */
|
|
||||||
const server = app.listen(PORT, () => {
|
const server = app.listen(PORT, () => {
|
||||||
console.log(`Backend API listening on ${PORT} (env=${NODE_ENV})`);
|
console.log(`Backend API listening on ${PORT} (env=${NODE_ENV})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ==========================
|
// Shutdown
|
||||||
* GRACEFUL SHUTDOWN
|
|
||||||
* ========================== */
|
|
||||||
async function shutdown(signal) {
|
async function shutdown(signal) {
|
||||||
try {
|
try {
|
||||||
console.log(`[SHUTDOWN] ${signal} received`);
|
console.log(`[SHUTDOWN] ${signal} received`);
|
||||||
await new Promise(resolve => server.close(resolve));
|
await new Promise((resolve) => server.close(resolve));
|
||||||
try { await sql.end(); } catch {}
|
try { await sql.end(); } catch {}
|
||||||
console.log('[SHUTDOWN] complete');
|
console.log("[SHUTDOWN] complete");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[SHUTDOWN] error', e);
|
console.error("[SHUTDOWN] error", e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -1,103 +1,43 @@
|
|||||||
import { sequelize } from '../db/sequelize.js';
|
// FILE: src/middleware/abac.js
|
||||||
import UPRModel from '../db/models/UserProjectRole.js';
|
// ABAC: Attribute-Based Access Control middleware helpers
|
||||||
|
// - Project-scoped access control base on user_project_roles + permissions
|
||||||
|
// - Requires req.user.roles and req.user.permissions to be populated (e.g. via auth.js with enrichment)
|
||||||
|
// - Uses UserProjectRole model to check project membership
|
||||||
|
// Helper ABAC เสริมบางเคส (ถ้าต้องการฟิลเตอร์/บังคับ project_id ตรง ๆ)
|
||||||
|
// หมายเหตุ: โดยหลักแล้วคุณควรใช้ requirePerm() ที่บังคับ ABAC อัตโนมัติจาก permissions.scope_level
|
||||||
|
|
||||||
/**
|
export function projectScopedViewFallback(moduleName) {
|
||||||
* ดึง project_id ที่ผู้ใช้เข้าถึงได้ (จาก user_project_roles)
|
// ใช้ในเคส legacy เท่านั้น
|
||||||
*/
|
|
||||||
export async function getUserProjectIds(user_id) {
|
|
||||||
const UPR = UPRModel(sequelize);
|
|
||||||
const rows = await UPR.findAll({ where: { user_id } });
|
|
||||||
return [...new Set(rows.map(r => r.project_id))];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* projectScopedView(moduleName) -> middleware
|
|
||||||
* - ต้องมี permission '<module>:view' หรือ
|
|
||||||
* - เป็นสมาชิกของโปรเจ็กต์ (ผ่าน user_project_roles)
|
|
||||||
* Behavior:
|
|
||||||
* - ถ้า query ไม่มี project_id และผู้ใช้ไม่ใช่ Admin:
|
|
||||||
* จำกัดผลลัพธ์ให้เฉพาะโปรเจ็กต์ที่ผู้ใช้เป็นสมาชิก
|
|
||||||
* - ถ้ามี project_id: บังคับตรวจสิทธิ์การเป็นสมาชิกของโปรเจ็กต์นั้น (เว้นแต่เป็น Admin)
|
|
||||||
*/
|
|
||||||
export function projectScopedView(moduleName) {
|
|
||||||
return async (req, res, next) => {
|
return async (req, res, next) => {
|
||||||
const roles = req.user?.roles || [];
|
const p = req.principal;
|
||||||
const isAdmin = roles.includes('Admin');
|
if (!p) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
const permName = `${moduleName}:view`;
|
|
||||||
const hasViewPerm = (req.user?.permissions || []).includes(permName);
|
|
||||||
|
|
||||||
// Admin ผ่านได้เสมอ
|
const hasViewPerm = p.can?.(`${moduleName}.view`) || p.permissions?.has?.(`${moduleName}.view`);
|
||||||
if (isAdmin) return next();
|
if (p.is_superadmin) return next();
|
||||||
|
|
||||||
const qProjectId = req.query?.project_id ? Number(req.query.project_id) : null;
|
const qProjectId = req.query?.project_id ? Number(req.query.project_id) : null;
|
||||||
const memberProjects = await getUserProjectIds(req.user?.user_id);
|
|
||||||
|
|
||||||
if (qProjectId) {
|
if (qProjectId) {
|
||||||
// ต้องเป็นสมาชิกโปรเจ็กต์นั้น หรือมี perm view
|
if (hasViewPerm || p.inProject(qProjectId)) return next();
|
||||||
if (hasViewPerm || memberProjects.includes(qProjectId)) return next();
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
return res.status(403).json({ error: 'Forbidden: not a member of project' });
|
|
||||||
} else {
|
} else {
|
||||||
// ไม่มี project_id: ถ้ามี perm view → อนุญาตทั้งหมด
|
|
||||||
// ถ้าไม่มี perm view → จำกัดด้วยรายการโปรเจ็กต์ที่เป็นสมาชิก (บันทึกไว้ใน req.abac.filterProjectIds)
|
|
||||||
if (hasViewPerm) return next();
|
if (hasViewPerm) return next();
|
||||||
if (!memberProjects.length) return res.status(403).json({ error: 'Forbidden: no accessible projects' });
|
if (!p.project_ids?.length) return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
req.abac = req.abac || {};
|
req.abac = req.abac || {};
|
||||||
req.abac.filterProjectIds = memberProjects;
|
req.abac.filterProjectIds = p.project_ids;
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* บังคับเป็นสมาชิกโปรเจ็กต์จากค่า project_id ใน body
|
|
||||||
* ใช้กับ create endpoints
|
|
||||||
*/
|
|
||||||
export function requireProjectMembershipFromBody() {
|
|
||||||
return async (req, res, next) => {
|
|
||||||
const roles = req.user?.roles || [];
|
|
||||||
const isAdmin = roles.includes('Admin');
|
|
||||||
if (isAdmin) return next();
|
|
||||||
const pid = Number(req.body?.project_id);
|
|
||||||
if (!pid) return res.status(400).json({ error: 'project_id required' });
|
|
||||||
const memberProjects = await getUserProjectIds(req.user?.user_id);
|
|
||||||
if (!memberProjects.includes(pid)) return res.status(403).json({ error: 'Forbidden: not a project member' });
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* บังคับเป็นสมาชิกโปรเจ็กต์โดยอ้างอิงจากเรคคอร์ด (ใช้กับ update/delete)
|
|
||||||
* opts: { modelLoader: (sequelize)=>Model, idParam: 'id', projectField: 'project_id' }
|
|
||||||
*/
|
|
||||||
export function requireProjectMembershipByRecord(opts) {
|
|
||||||
const { modelLoader, idParam='id', projectField='project_id' } = opts;
|
|
||||||
return async (req, res, next) => {
|
|
||||||
const roles = req.user?.roles || [];
|
|
||||||
const isAdmin = roles.includes('Admin');
|
|
||||||
if (isAdmin) return next();
|
|
||||||
const id = Number(req.params[idParam]);
|
|
||||||
if (!id) return res.status(400).json({ error: 'Invalid id' });
|
|
||||||
const Model = modelLoader(sequelize);
|
|
||||||
const row = await Model.findByPk(id);
|
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
|
||||||
const pid = Number(row[projectField]);
|
|
||||||
const memberProjects = await getUserProjectIds(req.user?.user_id);
|
|
||||||
if (!memberProjects.includes(pid)) return res.status(403).json({ error: 'Forbidden: not a project member' });
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* บังคับให้ view ทุกอันต้องส่ง project_id (ยกเว้น Admin)
|
|
||||||
*/
|
|
||||||
export function requireProjectIdQuery() {
|
export function requireProjectIdQuery() {
|
||||||
return async (req, res, next) => {
|
return (req, res, next) => {
|
||||||
const roles = req.user?.roles || [];
|
const p = req.principal;
|
||||||
const isAdmin = roles.includes('Admin');
|
if (!p) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
if (isAdmin) return next();
|
if (p.is_superadmin) return next();
|
||||||
const qProjectId = req.query?.project_id ? Number(req.query.project_id) : null;
|
const qProjectId = req.query?.project_id ? Number(req.query.project_id) : null;
|
||||||
if (!qProjectId) return res.status(400).json({ error: 'project_id query required' });
|
if (!qProjectId) return res.status(400).json({ error: "project_id query required" });
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
61
backend/src/middleware/auth copy.js
Executable file
61
backend/src/middleware/auth copy.js
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
// FILE: backend/src/middleware/auth.js
|
||||||
|
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { User, Role, UserRole } from "../db/sequelize.js";
|
||||||
|
|
||||||
|
export function signAccessToken(payload) {
|
||||||
|
return jwt.sign(payload, config.JWT.SECRET, {
|
||||||
|
expiresIn: config.JWT.EXPIRES_IN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function signRefreshToken(payload) {
|
||||||
|
return jwt.sign(payload, config.JWT.REFRESH_SECRET, {
|
||||||
|
expiresIn: config.JWT.REFRESH_EXPIRES_IN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractToken(req) {
|
||||||
|
// ให้คุกกี้มาก่อน แล้วค่อย Bearer (รองรับทั้งสองทาง)
|
||||||
|
const cookieTok = req.cookies?.access_token || null;
|
||||||
|
if (cookieTok) return cookieTok;
|
||||||
|
const hdr = req.headers.authorization || "";
|
||||||
|
return hdr.startsWith("Bearer ") ? hdr.slice(7) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuth(req, res, next) {
|
||||||
|
if (req.path === "/health") return next(); // อนุญาต health เสมอ
|
||||||
|
const token = extractToken(req);
|
||||||
|
if (!token) return res.status(401).json({ error: "Missing token" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
req.user = jwt.verify(token, config.JWT.SECRET);
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
return res.status(401).json({ error: "Invalid/Expired token" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ใช้กับเส้นทางที่ login แล้วจะ enrich ต่อได้ แต่ไม่บังคับ
|
||||||
|
export function optionalAuth(req, _res, next) {
|
||||||
|
const token = extractToken(req);
|
||||||
|
if (!token) return next();
|
||||||
|
try {
|
||||||
|
req.user = jwt.verify(token, config.JWT.SECRET);
|
||||||
|
} catch {}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrichRoles(req, _res, next) {
|
||||||
|
if (!req.user?.user_id) return next();
|
||||||
|
const rows = await UserRole.findAll({
|
||||||
|
where: { user_id: req.user.user_id },
|
||||||
|
include: [{ model: Role }],
|
||||||
|
}).catch(() => []);
|
||||||
|
req.user.roles = rows.map((r) => r.role?.role_name).filter(Boolean);
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPerm(req, perm) {
|
||||||
|
const set = new Set(req?.user?.permissions || []);
|
||||||
|
return set.has(perm);
|
||||||
|
}
|
||||||
43
backend/src/middleware/auth.js
Normal file → Executable file
43
backend/src/middleware/auth.js
Normal file → Executable file
@@ -1,37 +1,30 @@
|
|||||||
import jwt from 'jsonwebtoken';
|
// FILE: backend/src/middleware/auth.js
|
||||||
import { config } from '../config.js';
|
// (ถ้ายังใช้อยู่) ปรับให้สอดคล้อง Bearer + principal
|
||||||
import { User, Role, UserRole } from '../db/sequelize.js';
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
export function signAccessToken(payload) {
|
export function signAccessToken(payload) {
|
||||||
return jwt.sign(payload, config.JWT.SECRET, { expiresIn: config.JWT.EXPIRES_IN });
|
const { JWT_SECRET = "dev-secret", JWT_EXPIRES_IN = "30m" } = process.env;
|
||||||
|
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, issuer: "dms-backend" });
|
||||||
}
|
}
|
||||||
export function signRefreshToken(payload) {
|
export function signRefreshToken(payload) {
|
||||||
return jwt.sign(payload, config.JWT.REFRESH_SECRET, { expiresIn: config.JWT.REFRESH_EXPIRES_IN });
|
const { JWT_REFRESH_SECRET = "dev-refresh", JWT_REFRESH_EXPIRES_IN = "30d" } = process.env;
|
||||||
|
return jwt.sign({ ...payload, t: "refresh" }, JWT_REFRESH_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN, issuer: "dms-backend" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ถ้าจะใช้ standalone (ไม่แนะนำถ้ามี authJwt แล้ว)
|
||||||
export function requireAuth(req, res, next) {
|
export function requireAuth(req, res, next) {
|
||||||
if (req.path === '/health') return next(); // อนุญาต health เสมอ
|
const h = req.headers.authorization || "";
|
||||||
const hdr = req.headers.authorization || '';
|
const m = /^Bearer\s+(.+)$/i.exec(h || "");
|
||||||
const token = hdr.startsWith('Bearer ') ? hdr.slice(7) : null;
|
if (!m) return res.status(401).json({ error: "Missing token" });
|
||||||
if (!token) return res.status(401).json({ error: 'Missing token' });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
req.user = jwt.verify(token, config.JWT.SECRET);
|
const { JWT_SECRET = "dev-secret" } = process.env;
|
||||||
|
const payload = jwt.verify(m[1], JWT_SECRET, { issuer: "dms-backend" });
|
||||||
|
req.auth = { user_id: payload.user_id, username: payload.username };
|
||||||
|
req.user = req.user || {};
|
||||||
|
req.user.user_id = payload.user_id;
|
||||||
|
req.user.username = payload.username;
|
||||||
next();
|
next();
|
||||||
} catch {
|
} catch {
|
||||||
return res.status(401).json({ error: 'Invalid/Expired token' });
|
return res.status(401).json({ error: "Invalid/Expired token" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enrichRoles(req, _res, next) {
|
|
||||||
if (!req.user?.user_id) return next();
|
|
||||||
const rows = await UserRole.findAll({ where: { user_id: req.user.user_id }, include: [{ model: Role }] })
|
|
||||||
.catch(() => []);
|
|
||||||
req.user.roles = rows.map(r => r.role?.role_name).filter(Boolean);
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasPerm(req, perm) {
|
|
||||||
const set = new Set(req?.user?.permissions || []);
|
|
||||||
return set.has(perm);
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,37 @@
|
|||||||
|
// FILE: src/middleware/authJwt.js
|
||||||
|
// 03.2 4) เพิ่ม middleware authJwt (ใหม่)
|
||||||
|
// นำ middleware นี้ไปใส่ หลัง verify JWT เสมอ เช่น app.use('/api', authJwt(), routes)
|
||||||
|
// Simple JWT authentication middleware example
|
||||||
|
// - For demonstration or simple use cases
|
||||||
|
// - Not as feature-rich as auth.js (no role/permission enrichment)
|
||||||
|
// - Can be used standalone or alongside auth.js
|
||||||
// authJwt.js – สมมติคุณมี JWT อยู่แล้ว (ปรับ verify ตามที่ใช้จริง)
|
// authJwt.js – สมมติคุณมี JWT อยู่แล้ว (ปรับ verify ตามที่ใช้จริง)
|
||||||
import jwt from 'jsonwebtoken';
|
// - ตรวจ token และเติม req.user
|
||||||
const { JWT_SECRET = 'dev-secret' } = process.env;
|
// - ไม่ได้เติม roles/permissions (ถ้าต้องการให้ใช้ auth.js แทนหรือร่วมกัน)
|
||||||
|
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
export function authJwt() {
|
export function authJwt() {
|
||||||
|
const { JWT_SECRET = "dev-secret" } = process.env;
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
const h = req.headers.authorization || '';
|
const h = req.headers.authorization || "";
|
||||||
const token = h.startsWith('Bearer ') ? h.slice(7) : null;
|
// const token = h.startsWith("Bearer ") ? h.slice(7) : null;
|
||||||
if (!token) return res.status(401).json({ error: 'Unauthenticated' });
|
const m = /^Bearer\s+(.+)$/i.exec(h || "");
|
||||||
|
//if (!token) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
|
if (!m) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, JWT_SECRET);
|
//const payload = jwt.verify(token, JWT_SECRET);
|
||||||
req.user = { user_id: payload.user_id, username: payload.username };
|
const payload = jwt.verify(m[1], JWT_SECRET, { issuer: "dms-backend" });
|
||||||
|
// แนบข้อมูลขั้นต่ำให้ middleware ถัดไป
|
||||||
|
req.auth = { user_id: payload.user_id, username: payload.username };
|
||||||
|
//req.user = { user_id: payload.user_id, username: payload.username };
|
||||||
|
// เผื่อโค้ดเก่าอ้างอิง req.user
|
||||||
|
req.user = req.user || {};
|
||||||
|
req.user.user_id = payload.user_id;
|
||||||
|
req.user.username = payload.username;
|
||||||
next();
|
next();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return res.status(401).json({ error: 'Invalid token' });
|
return res.status(401).json({ error: "Unauthenticated" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
// FILE: src/middleware/errorHandler.js
|
||||||
|
// Error handling middleware
|
||||||
|
// - 404 Not Found handler
|
||||||
|
// - General error handler
|
||||||
|
// - Should be the last middleware added
|
||||||
|
|
||||||
export function notFound(_req, res, _next) {
|
export function notFound(_req, res, _next) {
|
||||||
res.status(404).json({ error: 'Not Found' });
|
res.status(404).json({ error: "Not Found" });
|
||||||
}
|
}
|
||||||
export function errorHandler(err, _req, res, _next) {
|
export function errorHandler(err, _req, res, _next) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(500).json({ error: 'Internal Server Error' });
|
res.status(500).json({ error: "Internal Server Error" });
|
||||||
}
|
}
|
||||||
|
|||||||
39
backend/src/middleware/index.js
Executable file
39
backend/src/middleware/index.js
Executable file
@@ -0,0 +1,39 @@
|
|||||||
|
// File: backend/src/middleware/index.js
|
||||||
|
import * as authJwt from "./authJwt.js";
|
||||||
|
import * as abac from "./abac.js";
|
||||||
|
import * as auth from "./auth.js";
|
||||||
|
import * as errorHandler from "./errorHandler.js";
|
||||||
|
import * as loadPrincipal from "./loadPrincipal.js";
|
||||||
|
import * as permGuard from "./permGuard.js";
|
||||||
|
import * as permissions from "./permissions.js";
|
||||||
|
import * as rbac from "./rbac.js";
|
||||||
|
import * as requirePerm from "./requirePerm.js";
|
||||||
|
|
||||||
|
// Export ทุกอย่างออกมาเป็น named exports
|
||||||
|
// เพื่อให้สามารถ import แบบ `import { authJwt, permGuard } from '../middleware';` ได้
|
||||||
|
export {
|
||||||
|
authJwt,
|
||||||
|
abac,
|
||||||
|
auth,
|
||||||
|
errorHandler,
|
||||||
|
loadPrincipal,
|
||||||
|
permGuard,
|
||||||
|
permissions,
|
||||||
|
rbac,
|
||||||
|
requirePerm,
|
||||||
|
};
|
||||||
|
|
||||||
|
// (Optional) สร้าง default export สำหรับกรณีที่ต้องการ import ทั้งหมดใน object เดียว
|
||||||
|
const middleware = {
|
||||||
|
authJwt,
|
||||||
|
abac,
|
||||||
|
auth,
|
||||||
|
errorHandler,
|
||||||
|
loadPrincipal,
|
||||||
|
permGuard,
|
||||||
|
permissions,
|
||||||
|
rbac,
|
||||||
|
requirePerm,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default middleware;
|
||||||
95
backend/src/middleware/loadPrincipal.js
Normal file → Executable file
95
backend/src/middleware/loadPrincipal.js
Normal file → Executable file
@@ -1,15 +1,98 @@
|
|||||||
// loadPrincipal.js
|
// FILE: src/middleware/loadPrincipal.js
|
||||||
import { loadPrincipal } from '../utils/rbac.js';
|
// 03.2 4) เพิ่ม middleware loadPrincipal (ใหม่)
|
||||||
|
// นำ middleware นี้ไปใส่ หลัง verify JWT เสมอ เช่น app.use('/api', authJwt(), loadPrincipalMw(), routes)
|
||||||
|
// Load principal (roles + permissions) middleware
|
||||||
|
// - Uses rbac.js utility to load principal info
|
||||||
|
// - Attaches to req.principal
|
||||||
|
// - Requires req.user.user_id to be populated (e.g. via auth.js or authJwt.js)
|
||||||
|
// โหลด principal จาก DB แล้วแนบไว้ใน req.principal
|
||||||
|
// NOTE: ตรงนี้สมมุติว่าคุณมี service/query ฝั่ง DB อยู่แล้ว (เช่น sql/Sequelize)
|
||||||
|
// ถ้าคุณมีฟังก์ชันโหลด principal อยู่ที่อื่น ให้แทน logic DB ตรง FIXME ด้านล่าง
|
||||||
|
// ใช้ req.auth.user_id และตั้ง req.principal ให้ครบ (RBAC + ABAC)
|
||||||
|
|
||||||
|
import sql from "../db/index.js";
|
||||||
|
|
||||||
export function loadPrincipalMw() {
|
export function loadPrincipalMw() {
|
||||||
return async (req, res, next) => {
|
return async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
if (!req.user?.user_id) return res.status(401).json({ error: 'Unauthenticated' });
|
const uid = req?.auth?.user_id || req?.user?.user_id;
|
||||||
req.principal = await loadPrincipal(req.user.user_id);
|
if (!uid) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
|
|
||||||
|
// --- 1) users (รวม org_id)
|
||||||
|
const [[u]] = await sql.query(
|
||||||
|
`SELECT user_id, username, email, first_name, last_name, org_id, is_active
|
||||||
|
FROM users WHERE user_id=? LIMIT 1`,
|
||||||
|
[uid]
|
||||||
|
);
|
||||||
|
if (!u || u.is_active === 0) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
|
|
||||||
|
// --- 2) roles (global)
|
||||||
|
const [roleRows] = await sql.query(
|
||||||
|
`SELECT r.role_id, r.role_code, r.role_name
|
||||||
|
FROM user_roles ur
|
||||||
|
JOIN roles r ON r.role_id = ur.role_id
|
||||||
|
WHERE ur.user_id=?`,
|
||||||
|
[uid]
|
||||||
|
);
|
||||||
|
const roleCodes = new Set(roleRows.map(r => r.role_code));
|
||||||
|
const is_superadmin = roleCodes.has("SUPER_ADMIN");
|
||||||
|
|
||||||
|
// --- 3) permissions (ผ่าน role_permissions)
|
||||||
|
const [permRows] = await sql.query(
|
||||||
|
`SELECT DISTINCT p.perm_code
|
||||||
|
FROM user_roles ur
|
||||||
|
JOIN role_permissions rp ON rp.role_id = ur.role_id
|
||||||
|
JOIN permissions p ON p.permission_id = rp.permission_id AND p.is_active=1
|
||||||
|
WHERE ur.user_id=?`,
|
||||||
|
[uid]
|
||||||
|
);
|
||||||
|
const permSet = new Set(permRows.map(x => x.perm_code));
|
||||||
|
|
||||||
|
// --- 4) project scope (user_project_roles)
|
||||||
|
const [projRows] = await sql.query(
|
||||||
|
`SELECT DISTINCT project_id FROM user_project_roles WHERE user_id=?`,
|
||||||
|
[uid]
|
||||||
|
);
|
||||||
|
const project_ids = projRows.map(r => r.project_id);
|
||||||
|
|
||||||
|
// --- 5) org scope: users.org_id + orgs จาก project_parties ของโปรเจ็คที่เข้าถึง
|
||||||
|
const baseOrgIds = u.org_id ? [u.org_id] : [];
|
||||||
|
let projOrgIds = [];
|
||||||
|
if (project_ids.length) {
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
`SELECT DISTINCT org_id FROM project_parties WHERE project_id IN (?)`,
|
||||||
|
[project_ids]
|
||||||
|
);
|
||||||
|
projOrgIds = rows.map(r => r.org_id);
|
||||||
|
}
|
||||||
|
const org_ids = Array.from(new Set([...baseOrgIds, ...projOrgIds]));
|
||||||
|
|
||||||
|
req.principal = {
|
||||||
|
user_id: u.user_id,
|
||||||
|
username: u.username,
|
||||||
|
email: u.email,
|
||||||
|
first_name: u.first_name,
|
||||||
|
last_name: u.last_name,
|
||||||
|
org_id: u.org_id || null,
|
||||||
|
|
||||||
|
roles: roleRows.map(r => ({ role_id: r.role_id, role_code: r.role_code, role_name: r.role_name })),
|
||||||
|
permissions: permSet, // Set ของ perm_code
|
||||||
|
project_ids,
|
||||||
|
org_ids,
|
||||||
|
is_superadmin,
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
can: (code) => is_superadmin || permSet.has(code),
|
||||||
|
canAny: (codes=[]) => is_superadmin || codes.some(c => permSet.has(c)),
|
||||||
|
canAll: (codes=[]) => is_superadmin || codes.every(c => permSet.has(c)),
|
||||||
|
inProject: (pid) => is_superadmin || project_ids.includes(Number(pid)),
|
||||||
|
inOrg: (oid) => is_superadmin || org_ids.includes(Number(oid)),
|
||||||
|
};
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('loadPrincipal error', err);
|
console.error("loadPrincipal error", err);
|
||||||
res.status(500).json({ error: 'Failed to load principal' });
|
res.status(500).json({ error: "Failed to load principal" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
/**
|
// FILE: src/middleware/permGuard.js
|
||||||
* requirePerm('rfa:create') => ตรวจว่ามี permission นี้ใน req.user.permissions
|
// Permission guard middleware
|
||||||
* ต้องแน่ใจว่าเรียก enrichPermissions() มาก่อน หรือคำนวณที่จุดเข้าใช้งาน
|
// - Checks if user has required permissions
|
||||||
*/
|
// - Requires req.user.permissions to be populated (e.g. via auth.js or authJwt.js with enrichment)
|
||||||
|
// เปลี่ยนให้เป็น wrapper ที่เรียก req.principal (ทางเก่ายังใช้ได้)**
|
||||||
|
|
||||||
export function requirePerm(...allowedPerms) {
|
export function requirePerm(...allowedPerms) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
const perms = req.user?.permissions || [];
|
const p = req.principal;
|
||||||
const ok = perms.some(p => allowedPerms.includes(p));
|
if (!p) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
if (!ok) return res.status(403).json({ error: 'Forbidden' });
|
const ok = p.is_superadmin || allowedPerms.some((code) => p.permissions?.has?.(code));
|
||||||
|
if (!ok) return res.status(403).json({ error: "FORBIDDEN", need_any_of: allowedPerms });
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,34 +1,40 @@
|
|||||||
import { Role, Permission, UserRole, RolePermission } from '../db/sequelize.js';
|
// FILE: src/middleware/permissions.js
|
||||||
|
// Permission calculation and enrichment middleware
|
||||||
|
// - Computes effective permissions for a user based on their roles
|
||||||
|
// - Attaches permissions to req.user.permissions
|
||||||
|
// ใช้เฉพาะกรณีที่คุณยังมี stack Sequelize เดิมอยู่ และอยาก enrich จาก Role/Permission model
|
||||||
|
// โดยทั่วไป ถ้าคุณใช้ loadPrincipalMw() อยู่แล้ว สามารถไม่ใช้ไฟล์นี้ได้
|
||||||
|
|
||||||
|
import { Permission, UserRole, RolePermission } from "../db/sequelize.js";
|
||||||
|
|
||||||
/**
|
|
||||||
* คืนชุด permission (string[]) ของ user_id
|
|
||||||
*/
|
|
||||||
export async function computeEffectivePermissions(user_id) {
|
export async function computeEffectivePermissions(user_id) {
|
||||||
// ดึง roles ของผู้ใช้
|
|
||||||
const userRoles = await UserRole.findAll({ where: { user_id } });
|
const userRoles = await UserRole.findAll({ where: { user_id } });
|
||||||
const roleIds = userRoles.map(r => r.role_id);
|
const roleIds = userRoles.map((r) => r.role_id);
|
||||||
if (!roleIds.length) return [];
|
if (!roleIds.length) return [];
|
||||||
|
|
||||||
// ดึง permission ผ่าน role_permissions
|
|
||||||
const rp = await RolePermission.findAll({ where: { role_id: roleIds } });
|
const rp = await RolePermission.findAll({ where: { role_id: roleIds } });
|
||||||
const permIds = [...new Set(rp.map(x => x.permission_id))];
|
const permIds = [...new Set(rp.map((x) => x.permission_id))];
|
||||||
if (!permIds.length) return [];
|
if (!permIds.length) return [];
|
||||||
|
|
||||||
const perms = await Permission.findAll({ where: { permission_id: permIds } });
|
const perms = await Permission.findAll({ where: { permission_id: permIds } });
|
||||||
return [...new Set(perms.map(p => p.permission_name))];
|
// ใช้ perm_code ให้สอดคล้อง seed
|
||||||
|
return [...new Set(perms.map((p) => p.perm_code))];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* middleware: เติม permissions ลง req.user.permissions
|
|
||||||
*/
|
|
||||||
export function enrichPermissions() {
|
export function enrichPermissions() {
|
||||||
return async (req, _res, next) => {
|
return async (req, _res, next) => {
|
||||||
if (!req.user?.user_id) return next();
|
const uid = req?.auth?.user_id || req?.user?.user_id;
|
||||||
|
if (!uid) return next();
|
||||||
try {
|
try {
|
||||||
const perms = await computeEffectivePermissions(req.user.user_id);
|
const perms = await computeEffectivePermissions(uid);
|
||||||
|
// อัปเดตทั้ง req.principal และ req.user (เผื่อโค้ดเก่า)
|
||||||
|
req.principal = req.principal || {};
|
||||||
|
req.principal.permissions = new Set(perms);
|
||||||
|
req.user = req.user || {};
|
||||||
req.user.permissions = perms;
|
req.user.permissions = perms;
|
||||||
} catch (e) {
|
} catch {
|
||||||
req.user.permissions = [];
|
if (req.principal) req.principal.permissions = new Set();
|
||||||
|
if (req.user) req.user.permissions = [];
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
|
// FILE: src/middleware/rbac.js
|
||||||
|
// RBAC: Role-Based Access Control middleware helpers
|
||||||
|
// - Role and Permission guard middleware
|
||||||
|
// - Requires req.user.roles and req.user.permissions to be populated (e.g. via auth.js with enrichment)
|
||||||
|
|
||||||
export function requireRole(...allowed) {
|
export function requireRole(...allowed) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
const roles = req.user?.roles || [];
|
const roles = (req.principal?.roles || []).map(r => r.role_code);
|
||||||
const ok = roles.some(r => allowed.includes(r));
|
const ok = roles.some((r) => allowed.includes(r)) || req.principal?.is_superadmin;
|
||||||
if (!ok) return res.status(403).json({ error: 'Forbidden' });
|
if (!ok) return res.status(403).json({ error: "FORBIDDEN_ROLE", need_any_of: allowed });
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requirePermission(...allowedPerms) {
|
export function requirePermissionCode(...codes) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
const perms = req.user?.permissions || [];
|
const p = req.principal;
|
||||||
const ok = perms.some(p => allowedPerms.includes(p));
|
if (!p) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
if (!ok) return res.status(403).json({ error: 'Forbidden' });
|
const ok = p.is_superadmin || codes.some((c) => p.permissions?.has?.(c));
|
||||||
|
if (!ok) return res.status(403).json({ error: "FORBIDDEN", need_any_of: codes });
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
18
backend/src/middleware/requireBearer.js
Executable file
18
backend/src/middleware/requireBearer.js
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
// FILE: src/middleware/requireBearer.js
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { findUserById } from "../db/models/users.js";
|
||||||
|
|
||||||
|
export async function requireBearer(req, res, next) {
|
||||||
|
const hdr = req.get("Authorization") || "";
|
||||||
|
const m = hdr.match(/^Bearer\s+(.+)$/i);
|
||||||
|
if (!m) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(m[1], process.env.JWT_ACCESS_SECRET, { issuer: "dms-backend" });
|
||||||
|
const user = await findUserById(payload.user_id);
|
||||||
|
if (!user) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
|
req.user = { user_id: user.user_id, username: user.username, email: user.email, first_name: user.first_name, last_name: user.last_name };
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
return res.status(401).json({ error: "Unauthenticated" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,64 @@
|
|||||||
// src/middleware/requirePerm.js
|
// FILE: src/middleware/requirePerm.js
|
||||||
import { canPerform } from '../utils/rbac.js';
|
// 03.2 4) เพิ่ม middleware requirePerm (ใหม่)
|
||||||
|
// นำ middleware นี้ไปใส่ หลัง loadPrincipal เสมอ เช่น app.use('/api', authJwt(), loadPrincipalMw(), requirePerm('correspondence.create', {scope:'org', getOrgId: req=>...}), routes)
|
||||||
|
// หรือใส่ใน route เดี่ยวๆ ก็ได้ เช่น router.post('/', requirePerm('correspondence.create', {scope:'org', getOrgId: req=>...}), (req,res)=>{...})
|
||||||
|
// Permission requirement middleware with scope support
|
||||||
|
// - Uses canPerform() utility from rbac.js
|
||||||
|
// - Supports global, org, and project scopes
|
||||||
|
// - Requires req.principal to be populated (e.g. via loadPrincipal middleware)
|
||||||
|
// เช็คตาม perm_code + ABAC อัตโนมัติจาก permissions.scope_level
|
||||||
|
import sql from "../db/index.js";
|
||||||
|
|
||||||
|
let _permMap = null;
|
||||||
|
let _loadedAt = 0;
|
||||||
|
const TTL_MS = 60_000;
|
||||||
|
|
||||||
|
async function getPermRegistry() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (_permMap && now - _loadedAt < TTL_MS) return _permMap;
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
`SELECT perm_code, scope_level FROM permissions WHERE is_active=1`
|
||||||
|
);
|
||||||
|
_permMap = new Map(rows.map(r => [r.perm_code, r.scope_level])); // GLOBAL | ORG | PROJECT
|
||||||
|
_loadedAt = now;
|
||||||
|
return _permMap;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* requirePerm('correspondence.create', { scope: 'org', getOrgId: req => ... })
|
* requirePerm('rfas.view', { projectParam: 'project_id', orgParam: 'org_id' })
|
||||||
* scope: 'global' | 'org' | 'project'
|
* - GLOBAL: แค่มี perm ก็ผ่าน
|
||||||
|
* - ORG: ต้องมี perm + อยู่ใน org scope (อ่าน org_id จาก param หากระบุ; ไม่ระบุจะใช้ req.principal.org_id)
|
||||||
|
* - PROJECT:ต้องมี perm + อยู่ใน project scope (อ่าน project_id จาก param)
|
||||||
*/
|
*/
|
||||||
export function requirePerm(permCode, { scope = 'global', getOrgId = null, getProjectId = null } = {}) {
|
export function requirePerm(permCode, { projectParam, orgParam } = {}) {
|
||||||
return async (req, res, next) => {
|
return async (req, res, next) => {
|
||||||
try {
|
const p = req.principal;
|
||||||
const orgId = getOrgId ? await getOrgId(req) : null;
|
if (!p) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
const projectId = getProjectId ? await getProjectId(req) : null;
|
|
||||||
|
|
||||||
if (canPerform(req.principal, permCode, { scope, orgId, projectId })) return next();
|
if (!(p.is_superadmin || p.permissions?.has?.(permCode))) {
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN", need: permCode });
|
||||||
return res.status(403).json({
|
|
||||||
error: 'FORBIDDEN',
|
|
||||||
message: `Require ${permCode} (${scope}-scoped)`,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('requirePerm error', e);
|
|
||||||
res.status(500).json({ error: 'Permission check error' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const registry = await getPermRegistry();
|
||||||
|
const scope = registry.get(permCode) || "GLOBAL";
|
||||||
|
|
||||||
|
const readParam = (name) => req.params?.[name] ?? req.query?.[name] ?? req.body?.[name];
|
||||||
|
|
||||||
|
if (scope === "PROJECT") {
|
||||||
|
const pid = Number(projectParam ? readParam(projectParam) : undefined);
|
||||||
|
if (!p.is_superadmin) {
|
||||||
|
if (!pid || !p.inProject(pid)) {
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT", project_id: pid || null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (scope === "ORG") {
|
||||||
|
const oid = Number(orgParam ? readParam(orgParam) : p.org_id);
|
||||||
|
if (!p.is_superadmin) {
|
||||||
|
if (!oid || !p.inOrg(oid)) {
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_ORG", org_id: oid || null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,27 +1,23 @@
|
|||||||
// src/routes/admin.js
|
// FILE: src/routes/admin.js
|
||||||
import { Router } from 'express';
|
import { Router } from "express";
|
||||||
import sequelize from '../db/index.js';
|
import os from "node:os";
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import sql from "../db/index.js";
|
||||||
import { requirePermission } from '../middleware/perm.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
// src/routes/admin.js
|
|
||||||
import { Router } from 'express';
|
|
||||||
import os from 'node:os';
|
|
||||||
import sql from '../db/index.js';
|
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
|
||||||
import PERM from '../config/permissions.js';
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
|
|
||||||
// GET /api/admin/sysinfo → ต้องมี admin.read
|
/**
|
||||||
r.get('/sysinfo',
|
* GET /api/admin/sysinfo
|
||||||
requirePerm(PERM.admin.read, { scope: 'global' }),
|
* perm: admin.access (ORG scope) – ใช้สิทธิ์กลุ่ม admin
|
||||||
async (req, res) => {
|
*/
|
||||||
|
r.get(
|
||||||
|
"/sysinfo",
|
||||||
|
requirePerm("admin.access", { orgParam: "org_id" }),
|
||||||
|
async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const [[{ now }]] = await sql.query('SELECT NOW() AS now');
|
await sql.query("SELECT 1");
|
||||||
res.json({
|
res.json({
|
||||||
now,
|
now: new Date().toISOString(),
|
||||||
node: process.version,
|
node: process.version,
|
||||||
platform: os.platform(),
|
platform: os.platform(),
|
||||||
arch: os.arch(),
|
arch: os.arch(),
|
||||||
@@ -29,80 +25,70 @@ r.get('/sysinfo',
|
|||||||
uptime_sec: os.uptime(),
|
uptime_sec: os.uptime(),
|
||||||
loadavg: os.loadavg(),
|
loadavg: os.loadavg(),
|
||||||
memory: { total: os.totalmem(), free: os.freemem() },
|
memory: { total: os.totalmem(), free: os.freemem() },
|
||||||
env: { NODE_ENV: process.env.NODE_ENV, APP_VERSION: process.env.APP_VERSION },
|
env: {
|
||||||
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
|
APP_VERSION: process.env.APP_VERSION,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: 'SYSINFO_FAIL', message: e?.message });
|
res.status(500).json({ error: "SYSINFO_FAIL", message: e?.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /api/admin/maintenance/reindex → ต้องมี admin.maintain
|
/**
|
||||||
r.post('/maintenance/reindex',
|
* POST /api/admin/maintenance/reindex
|
||||||
requirePerm(PERM.admin.maintain, { scope: 'global' }),
|
* perm: settings.manage (GLOBAL) – งานดูแลระบบ
|
||||||
|
*/
|
||||||
|
r.post(
|
||||||
|
"/maintenance/reindex",
|
||||||
|
requirePerm("settings.manage"),
|
||||||
async (_req, res) => {
|
async (_req, res) => {
|
||||||
// ตัวอย่าง: ANALYZE/OPTIMIZE ตารางสำคัญ (ปรับตามจริง)
|
|
||||||
try {
|
try {
|
||||||
await sql.query('ANALYZE TABLE correspondences, rfas, drawings');
|
// ปรับตามตารางจริงของคุณ
|
||||||
|
await sql.query("ANALYZE TABLE correspondences, rfas, drawings");
|
||||||
res.json({ ok: 1 });
|
res.json({ ok: 1 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: 'MAINT_FAIL', message: e?.message });
|
res.status(500).json({ error: "MAINT_FAIL", message: e?.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/perm-matrix?format=json
|
||||||
|
* perm: admin.access (ORG)
|
||||||
|
*/
|
||||||
|
r.get(
|
||||||
|
"/perm-matrix",
|
||||||
|
requirePerm("admin.access", { orgParam: "org_id" }),
|
||||||
|
async (req, res) => {
|
||||||
|
const format = String(req.query.format || "json").toLowerCase();
|
||||||
|
const [roles] = await sql.query(
|
||||||
|
`SELECT r.role_id, r.role_code, r.role_name,
|
||||||
|
GROUP_CONCAT(p.perm_code ORDER BY p.perm_code SEPARATOR ', ') AS perm_codes
|
||||||
|
FROM roles r
|
||||||
|
LEFT JOIN role_permissions rp ON rp.role_id = r.role_id
|
||||||
|
LEFT JOIN permissions p ON p.permission_id = rp.permission_id
|
||||||
|
GROUP BY r.role_id, r.role_code, r.role_name
|
||||||
|
ORDER BY r.role_code`
|
||||||
|
);
|
||||||
|
if (format === "json") return res.json({ roles });
|
||||||
|
// markdown แบบง่าย
|
||||||
|
const lines = [
|
||||||
|
`# Permission Matrix`,
|
||||||
|
`_Generated at: ${new Date().toISOString()}_`,
|
||||||
|
`| # | Role Code | Role Name | Permissions |`,
|
||||||
|
`|---:|:---------|:----------|:------------|`,
|
||||||
|
...roles.map(
|
||||||
|
(r, i) =>
|
||||||
|
`| ${i + 1} | \`${r.role_code}\` | ${r.role_name || ""} | ${
|
||||||
|
r.perm_codes || ""
|
||||||
|
} |`
|
||||||
|
),
|
||||||
|
];
|
||||||
|
res.setHeader("Content-Type", "text/markdown; charset=utf-8");
|
||||||
|
res.send(lines.join("\n"));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/admin/perm-matrix
|
|
||||||
* query:
|
|
||||||
* format=md|json (default: md)
|
|
||||||
*
|
|
||||||
* ต้องมีสิทธิ์ ADMIN หรืออย่างน้อย CDWG_ADMIN/ALL (เปลี่ยนเป็นอะไรก็ได้ตามนโยบายคุณ)
|
|
||||||
*/
|
|
||||||
router.get('/perm-matrix',
|
|
||||||
requireAuth,
|
|
||||||
// ใช้ ANY จากชุดสิทธิ์ด้านล่าง (คุณปรับให้เป็น ['ALL'] อย่างเดียวก็ได้)
|
|
||||||
requirePermission(['ALL', 'CDWG_ADMIN'], { mode: 'any' }),
|
|
||||||
async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const format = (req.query.format || 'md').toLowerCase();
|
|
||||||
|
|
||||||
// ดึง Role → Permissions (global)
|
|
||||||
const [rows] = await sequelize.query(`
|
|
||||||
SELECT
|
|
||||||
r.role_id,
|
|
||||||
r.role_code,
|
|
||||||
r.role_name,
|
|
||||||
GROUP_CONCAT(DISTINCT p.perm_code ORDER BY p.perm_code SEPARATOR ', ') AS perm_codes
|
|
||||||
FROM roles r
|
|
||||||
LEFT JOIN role_permissions rp ON rp.role_id = r.role_id
|
|
||||||
LEFT JOIN permissions p ON p.perm_id = rp.perm_id
|
|
||||||
GROUP BY r.role_id, r.role_code, r.role_name
|
|
||||||
ORDER BY r.role_code
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (format === 'json') {
|
|
||||||
return res.json({ roles: rows });
|
|
||||||
}
|
|
||||||
|
|
||||||
// สร้าง Markdown
|
|
||||||
const lines = [];
|
|
||||||
lines.push(`# Permission Matrix (Role → Permissions)`);
|
|
||||||
lines.push(`_Generated at: ${new Date().toISOString()}_\n`);
|
|
||||||
lines.push(`| # | Role Code | Role Name | Permissions |`);
|
|
||||||
lines.push(`|---:|:---------|:----------|:------------|`);
|
|
||||||
rows.forEach((r, idx) => {
|
|
||||||
lines.push(`| ${idx + 1} | \`${r.role_code}\` | ${r.role_name || ''} | ${r.perm_codes || ''} |`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const md = lines.join('\n');
|
|
||||||
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
|
|
||||||
return res.send(md);
|
|
||||||
} catch (e) {
|
|
||||||
next(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|||||||
137
backend/src/routes/auth พัง.js
Normal file
137
backend/src/routes/auth พัง.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// backend/src/routes/auth.js
|
||||||
|
import { Router } from "express";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { User } from "../db/sequelize.js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
signAccessToken,
|
||||||
|
signRefreshToken,
|
||||||
|
requireAuth,
|
||||||
|
} from "../middleware/auth.js";
|
||||||
|
|
||||||
|
const r = Router();
|
||||||
|
|
||||||
|
// cookie options — ทำในไฟล์นี้เลย (ไม่เพิ่ม utils ใหม่)
|
||||||
|
function cookieOpts(maxAgeMs) {
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
const opts = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true, // หลัง Nginx/HTTPS
|
||||||
|
sameSite: "none", // ส่งข้าม subdomain ได้
|
||||||
|
path: "/",
|
||||||
|
maxAge: maxAgeMs,
|
||||||
|
};
|
||||||
|
if (config.COOKIE_DOMAIN) opts.domain = config.COOKIE_DOMAIN; // เช่น .np-dms.work
|
||||||
|
if (!isProd && process.env.ALLOW_INSECURE_COOKIE === "1") {
|
||||||
|
opts.secure = false;
|
||||||
|
opts.sameSite = "lax";
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper TTL จาก config เดิม
|
||||||
|
const ACCESS_TTL_MS = (() => {
|
||||||
|
// รับทั้งรูปแบบ "15m" (เช่น EXPIRES_IN) หรือ milliseconds
|
||||||
|
// ถ้าเป็นเลขอยู่แล้ว (ms) ก็ใช้เลย
|
||||||
|
if (/^\d+$/.test(String(config.JWT.EXPIRES_IN)))
|
||||||
|
return Number(config.JWT.EXPIRES_IN);
|
||||||
|
// แปลงรูปแบบเช่น "15m" เป็น ms แบบง่าย ๆ
|
||||||
|
const s = String(config.JWT.EXPIRES_IN || "15m");
|
||||||
|
const n = parseInt(s, 10);
|
||||||
|
if (s.endsWith("h")) return n * 60 * 60 * 1000;
|
||||||
|
if (s.endsWith("m")) return n * 60 * 1000;
|
||||||
|
if (s.endsWith("s")) return n * 1000;
|
||||||
|
return 15 * 60 * 1000;
|
||||||
|
})();
|
||||||
|
const REFRESH_TTL_MS = (() => {
|
||||||
|
if (/^\d+$/.test(String(config.JWT.REFRESH_EXPIRES_IN)))
|
||||||
|
return Number(config.JWT.REFRESH_EXPIRES_IN);
|
||||||
|
const s = String(config.JWT.REFRESH_EXPIRES_IN || "7d");
|
||||||
|
const n = parseInt(s, 10);
|
||||||
|
if (s.endsWith("d")) return n * 24 * 60 * 60 * 1000;
|
||||||
|
if (s.endsWith("h")) return n * 60 * 60 * 1000;
|
||||||
|
if (s.endsWith("m")) return n * 60 * 1000;
|
||||||
|
if (s.endsWith("s")) return n * 1000;
|
||||||
|
return 7 * 24 * 60 * 60 * 1000;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// == POST /api/auth/login ==
|
||||||
|
r.post("/login", async (req, res) => {
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
if (!username || !password)
|
||||||
|
return res.status(400).json({ error: "USERNAME_PASSWORD_REQUIRED" });
|
||||||
|
|
||||||
|
const user = await User.findOne({ where: { username }, raw: true });
|
||||||
|
if (!user) return res.status(401).json({ error: "INVALID_CREDENTIALS" });
|
||||||
|
|
||||||
|
const ok = await bcrypt.compare(password, user.password_hash || "");
|
||||||
|
if (!ok) return res.status(401).json({ error: "INVALID_CREDENTIALS" });
|
||||||
|
|
||||||
|
// NOTE: สิทธิ์จริง ๆ ให้ดึงจากตาราง role/permission ของคุณ
|
||||||
|
const permissions = []; // ใส่เปล่าไว้ก่อน (คุณมี enrichRoles ที่อื่นอยู่แล้ว)
|
||||||
|
const payload = {
|
||||||
|
user_id: user.user_id,
|
||||||
|
username: user.username,
|
||||||
|
permissions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const access = signAccessToken(payload);
|
||||||
|
const refresh = signRefreshToken({ user_id: user.user_id });
|
||||||
|
|
||||||
|
// ตั้งคุกกี้ (และยังส่ง token ใน body ได้ถ้าคุณใช้อยู่)
|
||||||
|
res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS));
|
||||||
|
res.cookie("refresh_token", refresh, cookieOpts(REFRESH_TTL_MS));
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
token: access,
|
||||||
|
refresh_token: refresh,
|
||||||
|
user: { user_id: user.user_id, username: user.username, email: user.email },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// == GET /api/auth/me ==
|
||||||
|
r.get("/me", requireAuth, async (req, res) => {
|
||||||
|
// enrich เพิ่มจากฐานได้ตามต้องการ; ตอนนี้เอาเบื้องต้นจาก token
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
user: {
|
||||||
|
user_id: req.user.user_id,
|
||||||
|
username: req.user.username,
|
||||||
|
permissions: req.user.permissions || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// == POST /api/auth/refresh ==
|
||||||
|
r.post("/refresh", async (req, res) => {
|
||||||
|
// รับจากคุกกี้ก่อน แล้วค่อย Authorization
|
||||||
|
const bearer = req.headers.authorization?.startsWith("Bearer ")
|
||||||
|
? req.headers.authorization.slice(7)
|
||||||
|
: null;
|
||||||
|
const rt = req.cookies?.refresh_token || bearer;
|
||||||
|
if (!rt) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// verify refresh โดยตรงด้วย config.JWT.REFRESH_SECRET (คงสไตล์เดิม)
|
||||||
|
const p = jwt.verify(rt, config.JWT.REFRESH_SECRET, { clockTolerance: 10 });
|
||||||
|
// โหลดสิทธิ์ล่าสุดจากฐานได้ ถ้าต้องการ; ตอนนี้ใส่ [] ไว้ก่อน
|
||||||
|
const permissions = [];
|
||||||
|
const access = signAccessToken({ user_id: p.user_id, permissions });
|
||||||
|
res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS));
|
||||||
|
return res.json({ ok: true, token: access });
|
||||||
|
} catch {
|
||||||
|
return res.status(401).json({ error: "Unauthenticated" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// == POST /api/auth/logout ==
|
||||||
|
r.post("/logout", (_req, res) => {
|
||||||
|
res.clearCookie("access_token", { path: "/" });
|
||||||
|
res.clearCookie("refresh_token", { path: "/" });
|
||||||
|
return res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default r;
|
||||||
@@ -1,57 +1,100 @@
|
|||||||
// src/routes/auth.js (ESM)
|
// FILE: backend/src/routes/auth.js (ESM) — Bearer only, refresh via header/body, forgot/reset password
|
||||||
import { Router } from 'express';
|
import { Router } from "express";
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from "jsonwebtoken";
|
||||||
import bcrypt from 'bcryptjs';
|
import sql from "../db/index.js";
|
||||||
import sql from '../db/index.js';
|
import { cookieOpts } from "../utils/cookie.js";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { requireAuth } from "../middleware/auth.js";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-access-secret';
|
/* =========================
|
||||||
const REFRESH_SECRET = process.env.REFRESH_SECRET || 'dev-refresh-secret';
|
* CONFIG & HELPERS
|
||||||
const ACCESS_TTL = process.env.ACCESS_TTL || '30m'; // 30 นาที
|
* ========================= */
|
||||||
const REFRESH_TTL = process.env.REFRESH_TTL || '30d'; // 30 วัน
|
// ใช้ค่าเดียวกับ middleware authJwt()
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || "dev-secret";
|
||||||
|
const REFRESH_SECRET = process.env.REFRESH_SECRET || "dev-refresh-secret";
|
||||||
|
const ACCESS_TTL = process.env.ACCESS_TTL || "30m";
|
||||||
|
const REFRESH_TTL = process.env.REFRESH_TTL || "30d";
|
||||||
|
// อายุของ reset token (นาที)
|
||||||
|
const RESET_TTL_MIN = Number(process.env.RESET_TTL_MIN || 30);
|
||||||
|
|
||||||
function signAccessToken(user) {
|
function signAccessToken(user) {
|
||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
{ user_id: user.user_id, username: user.username },
|
{ user_id: user.user_id, username: user.username },
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: ACCESS_TTL, issuer: 'dms-backend' }
|
{ expiresIn: ACCESS_TTL, issuer: "dms-backend" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function signRefreshToken(user) {
|
function signRefreshToken(user) {
|
||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
{ user_id: user.user_id, username: user.username, t: 'refresh' },
|
{ user_id: user.user_id, username: user.username, t: "refresh" },
|
||||||
REFRESH_SECRET,
|
REFRESH_SECRET,
|
||||||
{ expiresIn: REFRESH_TTL, issuer: 'dms-backend' }
|
{ expiresIn: REFRESH_TTL, issuer: "dms-backend" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBearer(req) {
|
||||||
|
const h = req.headers.authorization || "";
|
||||||
|
if (!h.startsWith("Bearer ")) return null;
|
||||||
|
const token = h.slice(7).trim();
|
||||||
|
return token || null;
|
||||||
|
}
|
||||||
|
|
||||||
async function findUserByUsername(username) {
|
async function findUserByUsername(username) {
|
||||||
const [[u]] = await sql.query(
|
const [rows] = await sql.query(
|
||||||
'SELECT user_id, username, password_hash, email, first_name, last_name FROM users WHERE username=?',
|
`SELECT user_id, username, email, first_name, last_name, password_hash
|
||||||
|
FROM users WHERE username=? LIMIT 1`,
|
||||||
[username]
|
[username]
|
||||||
);
|
);
|
||||||
return u || null;
|
return rows?.[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/auth/login
|
async function findUserByEmail(email) {
|
||||||
r.post('/login', async (req, res) => {
|
const [rows] = await sql.query(
|
||||||
|
`SELECT user_id, username, email, first_name, last_name, password_hash
|
||||||
|
FROM users WHERE email=? LIMIT 1`,
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
return rows?.[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
* POST /api/auth/login
|
||||||
|
* - รับ username/password
|
||||||
|
* - ตรวจ bcrypt แล้วออก token+refresh_token (JSON)
|
||||||
|
* ========================= */
|
||||||
|
r.post("/login", async (req, res) => {
|
||||||
const { username, password } = req.body || {};
|
const { username, password } = req.body || {};
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
return res.status(400).json({ error: 'username and password required' });
|
return res.status(400).json({ error: "USERNAME_PASSWORD_REQUIRED" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await findUserByUsername(username);
|
const user = await findUserByUsername(username);
|
||||||
if (!user) return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
|
if (!user) return res.status(401).json({ error: "INVALID_CREDENTIALS" });
|
||||||
|
|
||||||
const ok = await bcrypt.compare(password, user.password_hash || '');
|
const ok = await bcrypt.compare(password, user.password_hash || "");
|
||||||
if (!ok) return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
|
if (!ok) return res.status(401).json({ error: "INVALID_CREDENTIALS" });
|
||||||
|
|
||||||
const access_token = signAccessToken(user);
|
const token = signAccessToken(user);
|
||||||
const refresh_token = signRefreshToken(user);
|
const refresh_token = signRefreshToken(user);
|
||||||
res.json({
|
|
||||||
token: access_token,
|
// set httpOnly cookies (ยังคงส่ง token ใน body กลับเช่นเดิม)
|
||||||
|
res.cookie(
|
||||||
|
"access_token",
|
||||||
|
token,
|
||||||
|
cookieOpts(parseInt(process.env.ACCESS_TTL_MS || "900000", 10))
|
||||||
|
);
|
||||||
|
res.cookie(
|
||||||
|
"refresh_token",
|
||||||
|
refresh_token,
|
||||||
|
cookieOpts(parseInt(process.env.REFRESH_TTL_MS || "604800000", 10))
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
token,
|
||||||
refresh_token,
|
refresh_token,
|
||||||
user: {
|
user: {
|
||||||
user_id: user.user_id,
|
user_id: user.user_id,
|
||||||
@@ -62,53 +105,175 @@ r.post('/login', async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
/* =========================
|
||||||
|
* GET /api/auth/me (cookie or bearer)
|
||||||
|
* ========================= */
|
||||||
|
r.get("/me", requireAuth, async (req, res) => {
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
user: { user_id: req.user.user_id, username: req.user.username },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
+(
|
||||||
|
/* =========================
|
||||||
|
* POST /api/auth/refresh
|
||||||
|
* - รองรับ refresh token จาก:
|
||||||
|
* 1) Authorization: Bearer <refresh_token>
|
||||||
|
* 2) req.body.refresh_token
|
||||||
|
* - ออก token ใหม่ + refresh ใหม่ (rotation)
|
||||||
|
* ========================= */
|
||||||
|
r.post("/refresh", async (req, res) => {
|
||||||
|
const fromHeader = getBearer(req);
|
||||||
|
const fromBody = (req.body || {}).refresh_token;
|
||||||
|
const refreshToken = fromHeader || fromBody;
|
||||||
|
if (!refreshToken) {
|
||||||
|
return res.status(400).json({ error: "REFRESH_TOKEN_REQUIRED" });
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/auth/refresh
|
try {
|
||||||
r.post('/refresh', async (req, res) => {
|
const payload = jwt.verify(refreshToken, REFRESH_SECRET, {
|
||||||
const { refresh_token } = req.body || {};
|
issuer: "dms-backend",
|
||||||
if (!refresh_token) return res.status(400).json({ error: 'refresh_token required' });
|
});
|
||||||
|
if (payload.t !== "refresh") throw new Error("bad token type");
|
||||||
|
|
||||||
try {
|
const [[user]] = await sql.query(
|
||||||
const payload = jwt.verify(refresh_token, REFRESH_SECRET, { issuer: 'dms-backend' });
|
`SELECT user_id, username, email, first_name, last_name
|
||||||
if (payload.t !== 'refresh') throw new Error('bad token');
|
FROM users WHERE user_id=? LIMIT 1`,
|
||||||
|
[payload.user_id]
|
||||||
|
);
|
||||||
|
if (!user) return res.status(401).json({ error: "USER_NOT_FOUND" });
|
||||||
|
|
||||||
// ยืนยันผู้ใช้ยังอยู่ในระบบ
|
// rotation
|
||||||
const [[user]] = await sql.query(
|
const token = signAccessToken(user);
|
||||||
'SELECT user_id, username FROM users WHERE user_id=?',
|
const new_refresh = signRefreshToken(user);
|
||||||
[payload.user_id]
|
|
||||||
|
// rotate cookies
|
||||||
|
res.cookie(
|
||||||
|
"access_token",
|
||||||
|
token,
|
||||||
|
cookieOpts(parseInt(process.env.ACCESS_TTL_MS || "900000", 10))
|
||||||
|
);
|
||||||
|
res.cookie(
|
||||||
|
"refresh_token",
|
||||||
|
new_refresh,
|
||||||
|
cookieOpts(parseInt(process.env.REFRESH_TTL_MS || "604800000", 10))
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
token,
|
||||||
|
refresh_token: new_refresh,
|
||||||
|
user: {
|
||||||
|
user_id: user.user_id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return res.status(401).json({ error: "INVALID_REFRESH_TOKEN" });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
* POST /api/auth/forgot-password
|
||||||
|
* - รับ username หรือ email อย่างใดอย่างหนึ่ง
|
||||||
|
* - สร้าง reset token แบบสุ่ม, เก็บ hash ใน DB พร้อมหมดอายุ
|
||||||
|
* - ส่งเสมอ {ok:true} เพื่อลด user enumeration
|
||||||
|
* - การ “ส่งอีเมล/ลิงก์รีเซ็ต” ให้ทำนอกระบบนี้ (เช่น n8n)
|
||||||
|
* ========================= */
|
||||||
|
r.post("/forgot-password", async (req, res) => {
|
||||||
|
const { username, email } = req.body || {};
|
||||||
|
// หา user จาก username หรือ email (ถ้ามีทั้งสอง จะให้ username มาก่อน)
|
||||||
|
let user = null;
|
||||||
|
if (username) user = await findUserByUsername(username);
|
||||||
|
if (!user && email) user = await findUserByEmail(email);
|
||||||
|
|
||||||
|
// สร้างโทเคน “เหมือนจริง” เสมอ (แต่ถ้าไม่เจอ user ก็ไม่บอก)
|
||||||
|
if (user) {
|
||||||
|
const raw = crypto.randomBytes(32).toString("hex"); // โทเคนดิบ (ส่งทางอีเมล)
|
||||||
|
const hash = crypto.createHash("sha256").update(raw).digest("hex"); // เก็บใน DB
|
||||||
|
const expires = new Date(Date.now() + RESET_TTL_MIN * 60 * 1000);
|
||||||
|
|
||||||
|
// ทำ invalid เก่า ๆ ของ user นี้ (optional)
|
||||||
|
await sql.query(
|
||||||
|
`UPDATE password_resets SET used_at=NOW()
|
||||||
|
WHERE user_id=? AND used_at IS NULL AND expires_at < NOW()`,
|
||||||
|
[user.user_id]
|
||||||
);
|
);
|
||||||
if (!user) return res.status(401).json({ error: 'USER_NOT_FOUND' });
|
|
||||||
|
|
||||||
const token = signAccessToken(user);
|
// บันทึก token ใหม่
|
||||||
const new_refresh = signRefreshToken(user); // rotation
|
await sql.query(
|
||||||
res.json({ token, refresh_token: new_refresh });
|
`INSERT INTO password_resets (user_id, token_hash, expires_at)
|
||||||
} catch (e) {
|
VALUES (?,?,?)`,
|
||||||
return res.status(401).json({ error: 'INVALID_REFRESH', message: e?.message });
|
[user.user_id, hash, expires]
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: ส่ง “raw token” ไปช่องทางปลอดภัย (เช่น n8n ส่งอีเมล)
|
||||||
|
// ตัวอย่างลิงก์ที่ frontend จะใช้:
|
||||||
|
// https://<frontend-domain>/reset-password?token=<raw>
|
||||||
|
// คุณสามารถต่อ webhook ไป n8n ได้ที่นี่ถ้าต้องการ
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ไม่บอกว่าเจอหรือไม่เจอ user
|
||||||
|
return res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/auth/logout (stateless)
|
/* =========================
|
||||||
r.post('/logout', (req, res) => {
|
* POST /api/auth/reset-password
|
||||||
// หากต้องการ blacklist/whitelist refresh token ให้เพิ่มตารางและบันทึกที่นี่
|
* - รับ token (จากลิงก์ในอีเมล) + new_password
|
||||||
res.json({ ok: 1 });
|
* - ตรวจ token_hash กับ DB, หมดอายุหรือถูกใช้ไปแล้วหรือยัง
|
||||||
});
|
* - เปลี่ยนรหัสผ่าน/ปิดใช้ token
|
||||||
|
* ========================= */
|
||||||
// POST /api/auth/change-password
|
r.post("/reset-password", async (req, res) => {
|
||||||
r.post('/change-password', async (req, res) => {
|
const { token, new_password } = req.body || {};
|
||||||
const { username, old_password, new_password } = req.body || {};
|
if (!token || !new_password) {
|
||||||
if (!username || !old_password || !new_password) {
|
return res.status(400).json({ error: "TOKEN_AND_NEW_PASSWORD_REQUIRED" });
|
||||||
return res.status(400).json({ error: 'username, old_password, new_password required' });
|
|
||||||
}
|
}
|
||||||
const user = await findUserByUsername(username);
|
|
||||||
if (!user) return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
|
||||||
|
|
||||||
const ok = await bcrypt.compare(old_password, user.password_hash || '');
|
const token_hash = crypto.createHash("sha256").update(token).digest("hex");
|
||||||
if (!ok) return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
|
|
||||||
|
|
||||||
|
const [[row]] = await sql.query(
|
||||||
|
`SELECT id, user_id, expires_at, used_at
|
||||||
|
FROM password_resets
|
||||||
|
WHERE token_hash=? LIMIT 1`,
|
||||||
|
[token_hash]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) return res.status(400).json({ error: "INVALID_TOKEN" });
|
||||||
|
if (row.used_at) return res.status(400).json({ error: "TOKEN_ALREADY_USED" });
|
||||||
|
if (new Date(row.expires_at).getTime() < Date.now()) {
|
||||||
|
return res.status(400).json({ error: "TOKEN_EXPIRED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// เปลี่ยนรหัสผ่าน
|
||||||
const salt = await bcrypt.genSalt(10);
|
const salt = await bcrypt.genSalt(10);
|
||||||
const hash = await bcrypt.hash(new_password, salt);
|
const hash = await bcrypt.hash(new_password, salt);
|
||||||
await sql.query('UPDATE users SET password_hash=? WHERE user_id=?', [hash, user.user_id]);
|
await sql.query(`UPDATE users SET password_hash=? WHERE user_id=?`, [
|
||||||
res.json({ ok: 1 });
|
hash,
|
||||||
|
row.user_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ปิดใช้ token นี้
|
||||||
|
await sql.query(`UPDATE password_resets SET used_at=NOW() WHERE id=?`, [
|
||||||
|
row.id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
* POST /api/auth/logout — stateless
|
||||||
|
* - frontend ลบ token เอง
|
||||||
|
* ========================= */
|
||||||
|
r.post("/logout", (_req, res) => {
|
||||||
|
res.clearCookie("access_token", { path: "/" });
|
||||||
|
res.clearCookie("refresh_token", { path: "/" });
|
||||||
|
return res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
|
|
||||||
|
// หมายเหตุ: คุณอาจเพิ่ม /register, /forgot-password, /reset-password ตามต้องการ
|
||||||
|
// แต่ในโปรเจกต์นี้จะให้แอดมินสร้างบัญชีผู้ใช้ผ่าน /api/users แทน
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { Router } from 'express';
|
// FILE: src/routes/auth_extras.js
|
||||||
import { requireAuth, enrichRoles } from '../middleware/auth.js';
|
// Deprecated for this project (เราใช้ Bearer + authJwt() แล้ว)
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
const r = Router();
|
const JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || "dev-access-secret";
|
||||||
|
export function requireAuth(req, res, next) {
|
||||||
r.get('/auth/me', requireAuth, enrichRoles, async (req, res) => {
|
const token = req.cookies?.access_token;
|
||||||
res.json({
|
if (!token) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
user_id: req.user?.user_id,
|
try {
|
||||||
username: req.user?.username,
|
const payload = jwt.verify(token, JWT_ACCESS_SECRET, {
|
||||||
roles: req.user?.roles || []
|
issuer: "dms-backend",
|
||||||
});
|
});
|
||||||
});
|
req.user = { user_id: payload.user_id, username: payload.username };
|
||||||
|
return next();
|
||||||
// Placeholder: client can simply drop tokens; provided for symmetry/logging hook
|
} catch {
|
||||||
r.post('/auth/logout', requireAuth, async (_req, res) => {
|
return res.status(401).json({ error: "INVALID_TOKEN" });
|
||||||
res.json({ ok: true });
|
}
|
||||||
});
|
}
|
||||||
|
export function requireRole(_role) {
|
||||||
export default r;
|
return (_req, res, next) => res.status(403).json({ error: "FORBIDDEN" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,66 +1,62 @@
|
|||||||
import { Router } from 'express';
|
// FILE: src/routes/categories.js
|
||||||
import sql from '../db/index.js';
|
// อ่าน: ใช้ organizations.view (GLOBAL)
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
// สร้าง/แก้/ลบ: ใช้ settings.manage (GLOBAL)
|
||||||
import PERM from '../config/permissions.js';
|
import { Router } from "express";
|
||||||
|
import sql from "../db/index.js";
|
||||||
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
|
|
||||||
|
// Categories
|
||||||
// Category LIST (global master, not scoped) – still require permission
|
r.get("/categories", requirePerm("organizations.view"), async (_req, res) => {
|
||||||
r.get('/categories',
|
const [rows] = await sql.query(
|
||||||
requirePerm(PERM.category.read, { scope: 'global' }),
|
"SELECT * FROM categories ORDER BY cat_id DESC"
|
||||||
async (req, res) => {
|
);
|
||||||
const [rows] = await sql.query('SELECT * FROM categories ORDER BY cat_id DESC');
|
res.json(rows);
|
||||||
res.json(rows);
|
});
|
||||||
}
|
r.post("/categories", requirePerm("settings.manage"), async (req, res) => {
|
||||||
);
|
const { cat_code, cat_name } = req.body || {};
|
||||||
|
if (!cat_code || !cat_name)
|
||||||
|
return res.status(400).json({ error: "cat_code and cat_name required" });
|
||||||
r.post('/categories',
|
const [rs] = await sql.query(
|
||||||
requirePerm(PERM.category.create, { scope: 'global' }),
|
"INSERT INTO categories (cat_code, cat_name) VALUES (?,?)",
|
||||||
async (req, res) => {
|
[cat_code, cat_name]
|
||||||
const { cat_code, cat_name } = req.body;
|
);
|
||||||
const [rs] = await sql.query('INSERT INTO categories (cat_code, cat_name) VALUES (?,?)', [cat_code, cat_name]);
|
res.json({ cat_id: rs.insertId });
|
||||||
res.json({ cat_id: rs.insertId });
|
});
|
||||||
}
|
r.put("/categories/:id", requirePerm("settings.manage"), async (req, res) => {
|
||||||
);
|
const id = Number(req.params.id);
|
||||||
|
const { cat_name } = req.body || {};
|
||||||
|
await sql.query("UPDATE categories SET cat_name=? WHERE cat_id=?", [
|
||||||
r.put('/categories/:id',
|
cat_name,
|
||||||
requirePerm(PERM.category.update, { scope: 'global' }),
|
id,
|
||||||
async (req, res) => {
|
]);
|
||||||
const id = Number(req.params.id);
|
res.json({ ok: 1 });
|
||||||
const { cat_name } = req.body;
|
});
|
||||||
await sql.query('UPDATE categories SET cat_name=? WHERE cat_id=?', [cat_name, id]);
|
r.delete(
|
||||||
res.json({ ok: 1 });
|
"/categories/:id",
|
||||||
}
|
requirePerm("settings.manage"),
|
||||||
);
|
async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
await sql.query("DELETE FROM categories WHERE cat_id=?", [id]);
|
||||||
r.delete('/categories/:id',
|
res.json({ ok: 1 });
|
||||||
requirePerm(PERM.category.delete, { scope: 'global' }),
|
}
|
||||||
async (req, res) => {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
await sql.query('DELETE FROM categories WHERE cat_id=?', [id]);
|
|
||||||
res.json({ ok: 1 });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
// Subcategories (belong to categories)
|
|
||||||
r.get('/subcategories',
|
|
||||||
requirePerm(PERM.category.read, { scope: 'global' }),
|
|
||||||
async (req, res) => {
|
|
||||||
const { cat_id } = req.query;
|
|
||||||
let sqlText = 'SELECT * FROM subcategories';
|
|
||||||
const params = [];
|
|
||||||
if (cat_id) { sqlText += ' WHERE cat_id=?'; params.push(Number(cat_id)); }
|
|
||||||
sqlText += ' ORDER BY sub_cat_id DESC';
|
|
||||||
const [rows] = await sql.query(sqlText, params);
|
|
||||||
res.json(rows);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Subcategories
|
||||||
|
r.get("/subcategories", requirePerm("organizations.view"), async (req, res) => {
|
||||||
|
const { cat_id } = req.query;
|
||||||
|
const params = [];
|
||||||
|
let where = "";
|
||||||
|
if (cat_id) {
|
||||||
|
where = " WHERE cat_id=?";
|
||||||
|
params.push(Number(cat_id));
|
||||||
|
}
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
`SELECT * FROM subcategories${where} ORDER BY sub_cat_id DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
@@ -1,74 +1,143 @@
|
|||||||
import { Router } from 'express';
|
// FILE: src/routes/contract_dwg.js
|
||||||
import sql from '../db/index.js';
|
// ใน seed ยังไม่มี contract_dwg.* → ผูกชั่วคราวกับสิทธิ์กลุ่ม drawings:
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
// read → drawings.view, create/update/delete → drawings.upload/delete (PROJECT scope)
|
||||||
import { buildScopeWhere, ownerResolvers } from '../utils/scope.js';
|
import { Router } from "express";
|
||||||
import PERM from '../config/permissions.js';
|
import sql from "../db/index.js";
|
||||||
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const OWN = ownerResolvers(sql, 'contract_dwg', 'id');
|
|
||||||
|
|
||||||
// LIST mappings
|
// LIST (PROJECT scope enforce via params ifส่ง project_id, ไม่ส่งจะถูก filter ด้วย scope ภายใน)
|
||||||
r.get('/',
|
r.get(
|
||||||
requirePerm(PERM.contract_dwg.read, { scope: 'global' }),
|
"/",
|
||||||
async (req, res) => {
|
requirePerm("drawings.view", { projectParam: "project_id" }),
|
||||||
const { project_id, org_id, condwg_no, limit=50, offset=0 } = req.query;
|
async (req, res) => {
|
||||||
const base = buildScopeWhere(req.principal, { tableAlias: 'm', orgColumn: 'm.org_id', projectColumn: 'm.project_id', permCode: PERM.contract_dwg.read, preferProject: true });
|
const { project_id, org_id, condwg_no, limit = 50, offset = 0 } = req.query;
|
||||||
const extra = [];
|
const p = req.principal;
|
||||||
const params = { ...base.params, limit: Number(limit), offset: Number(offset) };
|
const params = [];
|
||||||
if (project_id) { extra.push('m.project_id = :project_id'); params.project_id = Number(project_id); }
|
const cond = [];
|
||||||
if (org_id) { extra.push('m.org_id = :org_id'); params.org_id = Number(org_id); }
|
|
||||||
if (condwg_no) { extra.push('m.condwg_no = :condwg_no'); params.condwg_no = condwg_no; }
|
// ABAC filter ฝั่ง server กันหลุดขอบเขต
|
||||||
const where = [base.where, ...extra].filter(Boolean).join(' AND ');
|
if (!p.is_superadmin) {
|
||||||
const [rows] = await sql.query(`SELECT m.* FROM contract_dwg m WHERE ${where} ORDER BY m.id DESC LIMIT :limit OFFSET :offset`, params);
|
if (project_id) {
|
||||||
res.json(rows);
|
if (!p.inProject(Number(project_id)))
|
||||||
}
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
cond.push("m.project_id=?");
|
||||||
|
params.push(Number(project_id));
|
||||||
|
} else if (p.project_ids?.length) {
|
||||||
|
cond.push(
|
||||||
|
`m.project_id IN (${p.project_ids.map(() => "?").join(",")})`
|
||||||
|
);
|
||||||
|
params.push(...p.project_ids);
|
||||||
|
}
|
||||||
|
} else if (project_id) {
|
||||||
|
cond.push("m.project_id=?");
|
||||||
|
params.push(Number(project_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org_id) {
|
||||||
|
cond.push("m.org_id=?");
|
||||||
|
params.push(Number(org_id));
|
||||||
|
}
|
||||||
|
if (condwg_no) {
|
||||||
|
cond.push("m.condwg_no=?");
|
||||||
|
params.push(condwg_no);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
`SELECT m.* FROM contract_dwg m ${where} ORDER BY m.id DESC LIMIT ? OFFSET ?`,
|
||||||
|
[...params, Number(limit), Number(offset)]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GET item (ตรวจ ABAC หลังอ่านเรคคอร์ด)
|
||||||
|
r.get("/:id", requirePerm("drawings.view"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const [[row]] = await sql.query("SELECT * FROM contract_dwg WHERE id=?", [
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const p = req.principal;
|
||||||
|
if (!p.is_superadmin && !p.inProject(row.project_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
res.json(row);
|
||||||
|
});
|
||||||
|
|
||||||
// GET mapping
|
// CREATE
|
||||||
r.get('/:id',
|
r.post(
|
||||||
requirePerm(PERM.contract_dwg.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
"/",
|
||||||
async (req, res) => {
|
requirePerm("drawings.upload", { projectParam: "project_id" }),
|
||||||
const id = Number(req.params.id);
|
async (req, res) => {
|
||||||
const [[row]] = await sql.query('SELECT * FROM contract_dwg WHERE id=?', [id]);
|
const {
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
org_id,
|
||||||
res.json(row);
|
project_id,
|
||||||
}
|
condwg_no,
|
||||||
|
title,
|
||||||
|
drawing_id,
|
||||||
|
volume_id,
|
||||||
|
sub_cat_id,
|
||||||
|
sub_no,
|
||||||
|
remark,
|
||||||
|
} = req.body || {};
|
||||||
|
if (!project_id || !condwg_no)
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "project_id and condwg_no required" });
|
||||||
|
const [rs] = await sql.query(
|
||||||
|
`INSERT INTO contract_dwg (org_id, project_id, condwg_no, title, drawing_id, volume_id, sub_cat_id, sub_no, remark, created_by)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
[
|
||||||
|
org_id || null,
|
||||||
|
project_id,
|
||||||
|
condwg_no,
|
||||||
|
title || null,
|
||||||
|
drawing_id || null,
|
||||||
|
volume_id || null,
|
||||||
|
sub_cat_id || null,
|
||||||
|
sub_no || null,
|
||||||
|
remark || null,
|
||||||
|
req.principal.user_id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
res.json({ id: rs.insertId });
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// CREATE mapping (1 drawing per contract or per rule)
|
|
||||||
r.post('/',
|
|
||||||
requirePerm(PERM.contract_dwg.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
|
|
||||||
async (req, res) => {
|
|
||||||
const { org_id, project_id, condwg_no, title, drawing_id, volume_id, sub_cat_id, sub_no, remark } = req.body;
|
|
||||||
const [rs] = await sql.query(`INSERT INTO contract_dwg (org_id, project_id, condwg_no, title, drawing_id, volume_id, sub_cat_id, sub_no, remark, created_by) VALUES (?,?,?,?,?,?,?,?,?,?)`, [org_id, project_id, condwg_no, title, drawing_id, volume_id, sub_cat_id, sub_no, remark, req.principal.userId]);
|
|
||||||
res.json({ id: rs.insertId });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
// UPDATE
|
// UPDATE
|
||||||
r.put('/:id',
|
r.put("/:id", requirePerm("drawings.upload"), async (req, res) => {
|
||||||
requirePerm(PERM.contract_dwg.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
const id = Number(req.params.id);
|
||||||
async (req, res) => {
|
const [[row]] = await sql.query("SELECT * FROM contract_dwg WHERE id=?", [
|
||||||
const id = Number(req.params.id);
|
id,
|
||||||
const { title, remark } = req.body;
|
]);
|
||||||
await sql.query('UPDATE contract_dwg SET title=?, remark=? WHERE id=?', [title, remark, id]);
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
res.json({ ok: 1 });
|
const p = req.principal;
|
||||||
}
|
if (!p.is_superadmin && !p.inProject(row.project_id))
|
||||||
);
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
|
||||||
|
const { title, remark } = req.body || {};
|
||||||
|
await sql.query("UPDATE contract_dwg SET title=?, remark=? WHERE id=?", [
|
||||||
|
title ?? row.title,
|
||||||
|
remark ?? row.remark,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE
|
// DELETE
|
||||||
r.delete('/:id',
|
r.delete("/:id", requirePerm("drawings.delete"), async (req, res) => {
|
||||||
requirePerm(PERM.contract_dwg.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
const id = Number(req.params.id);
|
||||||
async (req, res) => {
|
const [[row]] = await sql.query("SELECT * FROM contract_dwg WHERE id=?", [
|
||||||
const id = Number(req.params.id);
|
id,
|
||||||
await sql.query('DELETE FROM contract_dwg WHERE id=?', [id]);
|
]);
|
||||||
res.json({ ok: 1 });
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
}
|
const p = req.principal;
|
||||||
);
|
if (!p.is_superadmin && !p.inProject(row.project_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
await sql.query("DELETE FROM contract_dwg WHERE id=?", [id]);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
@@ -1,72 +1,141 @@
|
|||||||
import { Router } from 'express';
|
// FILE: src/routes/contracts.js
|
||||||
import sql from '../db/index.js';
|
// ไม่มี contract.* ใน seed → map เป็นงานดูแลองค์กร/โปรเจ็กต์:
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
// list/get → projects.view (ORG)
|
||||||
import { buildScopeWhere, ownerResolvers } from '../utils/scope.js';
|
// create/update/delete → projects.manage (ORG)
|
||||||
import PERM from '../config/permissions.js';
|
import { Router } from "express";
|
||||||
|
import sql from "../db/index.js";
|
||||||
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const OWN = ownerResolvers(sql, 'contracts', 'id');
|
|
||||||
|
|
||||||
|
// LIST
|
||||||
|
r.get(
|
||||||
|
"/",
|
||||||
|
requirePerm("projects.view", { orgParam: "org_id" }),
|
||||||
|
async (req, res) => {
|
||||||
|
const {
|
||||||
|
project_id,
|
||||||
|
org_id,
|
||||||
|
contract_no,
|
||||||
|
q,
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
} = req.query;
|
||||||
|
const p = req.principal;
|
||||||
|
const params = [];
|
||||||
|
const cond = [];
|
||||||
|
if (!p.is_superadmin) {
|
||||||
|
if (org_id) {
|
||||||
|
if (!p.inOrg(Number(org_id)))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_ORG" });
|
||||||
|
cond.push("c.org_id=?");
|
||||||
|
params.push(Number(org_id));
|
||||||
|
} else if (p.org_ids?.length) {
|
||||||
|
cond.push(`c.org_id IN (${p.org_ids.map(() => "?").join(",")})`);
|
||||||
|
params.push(...p.org_ids);
|
||||||
|
}
|
||||||
|
} else if (org_id) {
|
||||||
|
cond.push("c.org_id=?");
|
||||||
|
params.push(Number(org_id));
|
||||||
|
}
|
||||||
|
|
||||||
r.get('/',
|
if (project_id) {
|
||||||
requirePerm(PERM.contract.read, { scope: 'global' }),
|
cond.push("c.project_id=?");
|
||||||
async (req, res) => {
|
params.push(Number(project_id));
|
||||||
const { project_id, org_id, contract_no, q, limit = 50, offset = 0 } = req.query;
|
}
|
||||||
const base = buildScopeWhere(req.principal, { tableAlias: 'c', orgColumn: 'c.org_id', projectColumn: 'c.project_id', permCode: PERM.contract.read, preferProject: true });
|
if (contract_no) {
|
||||||
const extra = [];
|
cond.push("c.contract_no=?");
|
||||||
const params = { ...base.params, limit: Number(limit), offset: Number(offset) };
|
params.push(contract_no);
|
||||||
if (project_id) { extra.push('c.project_id = :project_id'); params.project_id = Number(project_id); }
|
}
|
||||||
if (org_id) { extra.push('c.org_id = :org_id'); params.org_id = Number(org_id); }
|
if (q) {
|
||||||
if (contract_no){ extra.push('c.contract_no = :contract_no'); params.contract_no = contract_no; }
|
cond.push("(c.contract_no LIKE ? OR c.title LIKE ?)");
|
||||||
if (q) { extra.push('(c.contract_no LIKE :q OR c.title LIKE :q)'); params.q = `%${q}%`; }
|
params.push(`%${q}%`, `%${q}%`);
|
||||||
const where = [base.where, ...extra].filter(Boolean).join(' AND ');
|
}
|
||||||
const [rows] = await sql.query(`SELECT c.* FROM contracts c WHERE ${where} ORDER BY c.id DESC LIMIT :limit OFFSET :offset`, params);
|
|
||||||
res.json(rows);
|
const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
|
||||||
}
|
const [rows] = await sql.query(
|
||||||
|
`SELECT c.* FROM contracts c ${where} ORDER BY c.id DESC LIMIT ? OFFSET ?`,
|
||||||
|
[...params, Number(limit), Number(offset)]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GET
|
||||||
r.get('/:id',
|
r.get(
|
||||||
requirePerm(PERM.contract.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
"/:id",
|
||||||
async (req, res) => {
|
requirePerm("projects.view", { orgParam: "org_id" }),
|
||||||
const id = Number(req.params.id);
|
async (req, res) => {
|
||||||
const [[row]] = await sql.query('SELECT * FROM contracts WHERE id=?', [id]);
|
const id = Number(req.params.id);
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
const [[row]] = await sql.query("SELECT * FROM contracts WHERE id=?", [id]);
|
||||||
res.json(row);
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
}
|
const p = req.principal;
|
||||||
|
if (!p.is_superadmin && !p.inOrg(row.org_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_ORG" });
|
||||||
|
res.json(row);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// CREATE
|
||||||
r.post('/',
|
r.post(
|
||||||
requirePerm(PERM.contract.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
|
"/",
|
||||||
async (req, res) => {
|
requirePerm("projects.manage", { orgParam: "org_id" }),
|
||||||
const { org_id, project_id, contract_no, title, status } = req.body;
|
async (req, res) => {
|
||||||
const [rs] = await sql.query(`INSERT INTO contracts (org_id, project_id, contract_no, title, status, created_by) VALUES (?,?,?,?,?,?)`, [org_id, project_id, contract_no, title, status, req.principal.userId]);
|
const { org_id, project_id, contract_no, title, status } = req.body || {};
|
||||||
res.json({ id: rs.insertId });
|
if (!org_id || !project_id || !contract_no)
|
||||||
}
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "org_id, project_id, contract_no required" });
|
||||||
|
const [rs] = await sql.query(
|
||||||
|
`INSERT INTO contracts (org_id, project_id, contract_no, title, status, created_by) VALUES (?,?,?,?,?,?)`,
|
||||||
|
[
|
||||||
|
org_id,
|
||||||
|
project_id,
|
||||||
|
contract_no,
|
||||||
|
title || null,
|
||||||
|
status || null,
|
||||||
|
req.principal.user_id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
res.json({ id: rs.insertId });
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// UPDATE
|
||||||
r.put('/:id',
|
r.put(
|
||||||
requirePerm(PERM.contract.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
"/:id",
|
||||||
async (req, res) => {
|
requirePerm("projects.manage", { orgParam: "org_id" }),
|
||||||
const id = Number(req.params.id);
|
async (req, res) => {
|
||||||
const { title, status } = req.body;
|
const id = Number(req.params.id);
|
||||||
await sql.query('UPDATE contracts SET title=?, status=? WHERE id=?', [title, status, id]);
|
const [[row]] = await sql.query("SELECT * FROM contracts WHERE id=?", [id]);
|
||||||
res.json({ ok: 1 });
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
}
|
const p = req.principal;
|
||||||
|
if (!p.is_superadmin && !p.inOrg(row.org_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_ORG" });
|
||||||
|
const { title, status } = req.body || {};
|
||||||
|
await sql.query("UPDATE contracts SET title=?, status=? WHERE id=?", [
|
||||||
|
title ?? row.title,
|
||||||
|
status ?? row.status,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// DELETE
|
||||||
r.delete('/:id',
|
r.delete(
|
||||||
requirePerm(PERM.contract.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
"/:id",
|
||||||
async (req, res) => {
|
requirePerm("projects.manage", { orgParam: "org_id" }),
|
||||||
const id = Number(req.params.id);
|
async (req, res) => {
|
||||||
await sql.query('DELETE FROM contracts WHERE id=?', [id]);
|
const id = Number(req.params.id);
|
||||||
res.json({ ok: 1 });
|
const [[row]] = await sql.query("SELECT * FROM contracts WHERE id=?", [id]);
|
||||||
}
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const p = req.principal;
|
||||||
|
if (!p.is_superadmin && !p.inOrg(row.org_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_ORG" });
|
||||||
|
await sql.query("DELETE FROM contracts WHERE id=?", [id]);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
@@ -1,74 +1,124 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/correspondences.js
|
||||||
import sql from '../db/index.js';
|
import { Router } from "express";
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
import sql from "../db/index.js";
|
||||||
import { buildScopeWhere, ownerResolvers } from '../utils/scope.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
import PERM from '../config/permissions.js';
|
|
||||||
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const OWN = ownerResolvers(sql, 'correspondences', 'id');
|
|
||||||
|
|
||||||
|
// LIST
|
||||||
|
r.get(
|
||||||
|
"/",
|
||||||
|
requirePerm("corr.view", { projectParam: "project_id" }),
|
||||||
|
async (req, res) => {
|
||||||
|
const { project_id, org_id, q, limit = 50, offset = 0 } = req.query;
|
||||||
|
const p = req.principal;
|
||||||
|
const params = [];
|
||||||
|
const cond = [];
|
||||||
|
|
||||||
r.get('/',
|
if (!p.is_superadmin) {
|
||||||
requirePerm(PERM.correspondence.read, { scope: 'global' }),
|
if (project_id) {
|
||||||
async (req, res) => {
|
if (!p.inProject(Number(project_id)))
|
||||||
const { project_id, org_id, q, limit = 50, offset = 0 } = req.query;
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
const base = buildScopeWhere(req.principal, {
|
cond.push("c.project_id=?");
|
||||||
tableAlias: 'c', orgColumn: 'c.org_id', projectColumn: 'c.project_id',
|
params.push(Number(project_id));
|
||||||
permCode: PERM.correspondence.read, preferProject: true,
|
} else if (p.project_ids?.length) {
|
||||||
|
cond.push(
|
||||||
|
`c.project_id IN (${p.project_ids.map(() => "?").join(",")})`
|
||||||
|
);
|
||||||
|
params.push(...p.project_ids);
|
||||||
|
}
|
||||||
|
} else if (project_id) {
|
||||||
|
cond.push("c.project_id=?");
|
||||||
|
params.push(Number(project_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org_id) {
|
||||||
|
cond.push("c.org_id=?");
|
||||||
|
params.push(Number(org_id));
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
cond.push("(c.corr_no LIKE ? OR c.subject LIKE ?)");
|
||||||
|
params.push(`%${q}%`, `%${q}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
`SELECT c.* FROM correspondences c ${where} ORDER BY c.id DESC LIMIT ? OFFSET ?`,
|
||||||
|
[...params, Number(limit), Number(offset)]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET
|
||||||
|
r.get("/:id", requirePerm("corr.view"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const [[row]] = await sql.query("SELECT * FROM correspondences WHERE id=?", [
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const p = req.principal;
|
||||||
|
if (!p.is_superadmin && !p.inProject(row.project_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
res.json(row);
|
||||||
});
|
});
|
||||||
const extra = [];
|
|
||||||
const params = { ...base.params, limit: Number(limit), offset: Number(offset) };
|
// CREATE
|
||||||
if (project_id) { extra.push('c.project_id = :project_id'); params.project_id = Number(project_id); }
|
r.post(
|
||||||
if (org_id) { extra.push('c.org_id = :org_id'); params.org_id = Number(org_id); }
|
"/",
|
||||||
if (q) { extra.push('(c.corr_no LIKE :q OR c.subject LIKE :q)'); params.q = `%${q}%`; }
|
requirePerm("corr.manage", { projectParam: "project_id" }),
|
||||||
const where = [base.where, ...extra].join(' AND ');
|
async (req, res) => {
|
||||||
const [rows] = await sql.query(`SELECT c.* FROM correspondences c WHERE ${where} ORDER BY c.id DESC LIMIT :limit OFFSET :offset`, params);
|
const { org_id, project_id, corr_no, subject, status } = req.body || {};
|
||||||
res.json(rows);
|
if (!project_id || !corr_no)
|
||||||
}
|
return res.status(400).json({ error: "project_id and corr_no required" });
|
||||||
|
const [rs] = await sql.query(
|
||||||
|
`INSERT INTO correspondences (org_id, project_id, corr_no, subject, status, created_by)
|
||||||
|
VALUES (?,?,?,?,?,?)`,
|
||||||
|
[
|
||||||
|
org_id || null,
|
||||||
|
project_id,
|
||||||
|
corr_no,
|
||||||
|
subject || null,
|
||||||
|
status || null,
|
||||||
|
req.principal.user_id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
res.json({ id: rs.insertId });
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// UPDATE
|
||||||
|
r.put("/:id", requirePerm("corr.manage"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const [[row]] = await sql.query("SELECT * FROM correspondences WHERE id=?", [
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const p = req.principal;
|
||||||
|
if (!p.is_superadmin && !p.inProject(row.project_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
|
||||||
r.get('/:id',
|
const { subject, status } = req.body || {};
|
||||||
requirePerm(PERM.correspondence.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
await sql.query("UPDATE correspondences SET subject=?, status=? WHERE id=?", [
|
||||||
async (req, res) => {
|
subject ?? row.subject,
|
||||||
const id = Number(req.params.id);
|
status ?? row.status,
|
||||||
const [[row]] = await sql.query('SELECT * FROM correspondences WHERE id=?', [id]);
|
id,
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
]);
|
||||||
res.json(row);
|
res.json({ ok: 1 });
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
r.post('/',
|
|
||||||
requirePerm(PERM.correspondence.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
|
|
||||||
async (req, res) => {
|
|
||||||
const { org_id, project_id, corr_no, subject, status } = req.body;
|
|
||||||
const [rs] = await sql.query(`INSERT INTO correspondences (org_id, project_id, corr_no, subject, status, created_by) VALUES (?,?,?,?,?,?)`, [org_id, project_id, corr_no, subject, status, req.principal.userId]);
|
|
||||||
res.json({ id: rs.insertId });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
r.put('/:id',
|
|
||||||
requirePerm(PERM.correspondence.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
|
||||||
async (req, res) => {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
const { subject, status } = req.body;
|
|
||||||
await sql.query('UPDATE correspondences SET subject=?, status=? WHERE id=?', [subject, status, id]);
|
|
||||||
res.json({ ok: 1 });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
r.delete('/:id',
|
|
||||||
requirePerm(PERM.correspondence.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
|
||||||
async (req, res) => {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
await sql.query('DELETE FROM correspondences WHERE id=?', [id]);
|
|
||||||
res.json({ ok: 1 });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
r.delete("/:id", requirePerm("corr.manage"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const [[row]] = await sql.query("SELECT * FROM correspondences WHERE id=?", [
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const p = req.principal;
|
||||||
|
if (!p.is_superadmin && !p.inProject(row.project_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
await sql.query("DELETE FROM correspondences WHERE id=?", [id]);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
56
backend/src/routes/dashboard copy.js
Normal file
56
backend/src/routes/dashboard copy.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// backend/src/routes/dashboard.js
|
||||||
|
import { Router } from "express";
|
||||||
|
import { Op } from "sequelize";
|
||||||
|
import { Correspondence, Document, RFA, User } from "../db/index.js"; // import models
|
||||||
|
import { authJwt } from "../middleware/index.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Middleware: ตรวจสอบสิทธิ์สำหรับทุก route ในไฟล์นี้
|
||||||
|
router.use(authJwt.verifyToken);
|
||||||
|
|
||||||
|
// === API สำหรับ User Management Widget ===
|
||||||
|
router.get("/users/summary", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const totalUsers = await User.count();
|
||||||
|
const activeUsers = await User.count({ where: { is_active: true } });
|
||||||
|
// ดึง user ที่สร้างล่าสุด 5 คน
|
||||||
|
const recentUsers = await User.findAll({
|
||||||
|
limit: 5,
|
||||||
|
order: [["createdAt", "DESC"]],
|
||||||
|
attributes: ["id", "username", "email", "createdAt"],
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
total: totalUsers,
|
||||||
|
active: activeUsers,
|
||||||
|
inactive: totalUsers - activeUsers,
|
||||||
|
recent: recentUsers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// === API อื่นๆ สำหรับ Dashboard ที่เราคุยกันไว้ก่อนหน้า ===
|
||||||
|
router.get("/stats", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const sevenDaysAgo = new Date(new Date().setDate(new Date().getDate() - 7));
|
||||||
|
|
||||||
|
const totalDocuments = await Document.count();
|
||||||
|
const newThisWeek = await Document.count({
|
||||||
|
where: { createdAt: { [Op.gte]: sevenDaysAgo } },
|
||||||
|
});
|
||||||
|
const pendingRfas = await RFA.count({ where: { status: "pending" } }); // สมมติตาม status
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
totalDocuments,
|
||||||
|
newThisWeek,
|
||||||
|
pendingRfas,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
63
backend/src/routes/dashboard.js
Executable file
63
backend/src/routes/dashboard.js
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
// backend/src/routes/dashboard.js
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
|
||||||
|
// 1. Import Middleware ที่ถูกต้อง
|
||||||
|
import { authJwt } from '../middleware/authJwt.js';
|
||||||
|
import { loadPrincipalMw } from '../middleware/loadPrincipal.js';
|
||||||
|
|
||||||
|
// 2. Import Sequelize Models จาก `sequelize.js` ไม่ใช่ `index.js`
|
||||||
|
import { Correspondence, Document, RFA, User } from '../db/sequelize.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 3. ใช้ Middleware Chain ที่ถูกต้อง 100%
|
||||||
|
router.use(authJwt(), loadPrincipalMw());
|
||||||
|
|
||||||
|
|
||||||
|
// === API สำหรับ User Management Widget ===
|
||||||
|
router.get('/users/summary', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// ตรวจสอบว่า Model ถูกโหลดแล้วหรือยัง (จำเป็นสำหรับโหมด lazy-load)
|
||||||
|
if (!User) {
|
||||||
|
return res.status(503).json({ message: 'Database models not available. Is ENABLE_SEQUELIZE=1 set?' });
|
||||||
|
}
|
||||||
|
const totalUsers = await User.count();
|
||||||
|
const activeUsers = await User.count({ where: { is_active: true } });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
total: totalUsers,
|
||||||
|
active: activeUsers,
|
||||||
|
inactive: totalUsers - activeUsers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// === API อื่นๆ สำหรับ Dashboard ที่เราคุยกันไว้ก่อนหน้า ===
|
||||||
|
router.get('/stats', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!Document || !RFA) {
|
||||||
|
return res.status(503).json({ message: 'Database models not available. Is ENABLE_SEQUELIZE=1 set?' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sevenDaysAgo = new Date(new Date().setDate(new Date().getDate() - 7));
|
||||||
|
|
||||||
|
const totalDocuments = await Document.count();
|
||||||
|
const newThisWeek = await Document.count({ where: { createdAt: { [Op.gte]: sevenDaysAgo } } });
|
||||||
|
const pendingRfas = await RFA.count({ where: { status: 'pending' } }); // สมมติตาม status
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
totalDocuments,
|
||||||
|
newThisWeek,
|
||||||
|
pendingRfas
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,58 +1,149 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/documents.js
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { Router } from "express";
|
||||||
import { enrichPermissions } from '../middleware/permissions.js';
|
import sql from "../db/index.js";
|
||||||
import { requireRole } from '../middleware/rbac.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
import { requirePerm } from '../middleware/permGuard.js';
|
|
||||||
import { sequelize } from '../db/sequelize.js';
|
|
||||||
import DocumentModel from '../db/models/Document.js';
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const Doc = DocumentModel(sequelize);
|
|
||||||
|
|
||||||
r.get('/documents', requireAuth, async (req, res) => {
|
// LIST
|
||||||
const { q, project_id, status, category, page=1, page_size=20 } = req.query;
|
r.get(
|
||||||
const limit = Math.min(Number(page_size)||20, 100);
|
"/",
|
||||||
const offset = (Math.max(Number(page)||1,1)-1) * limit;
|
requirePerm("documents.view", { projectParam: "project_id" }),
|
||||||
|
async (req, res) => {
|
||||||
|
const {
|
||||||
|
q,
|
||||||
|
project_id,
|
||||||
|
status,
|
||||||
|
category,
|
||||||
|
page = 1,
|
||||||
|
page_size = 20,
|
||||||
|
} = req.query;
|
||||||
|
const limit = Math.min(Number(page_size) || 20, 100);
|
||||||
|
const offset = (Math.max(Number(page) || 1, 1) - 1) * limit;
|
||||||
|
|
||||||
const where = {};
|
const p = req.principal;
|
||||||
if (project_id) where.project_id = project_id;
|
const params = [];
|
||||||
if (status) where.status = status;
|
const cond = [];
|
||||||
if (category) where.category = category;
|
|
||||||
if (q) where.title = sequelize.where(sequelize.fn('LOWER', sequelize.col('title')), 'LIKE', `%${String(q).toLowerCase()}%`);
|
|
||||||
|
|
||||||
const { rows, count } = await Doc.findAndCountAll({ where, limit, offset, order:[['created_at','DESC']] });
|
if (!p.is_superadmin) {
|
||||||
res.json({ items: rows, total: count, page: Number(page), page_size: limit });
|
if (project_id) {
|
||||||
});
|
if (!p.inProject(Number(project_id)))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
cond.push("d.project_id=?");
|
||||||
|
params.push(Number(project_id));
|
||||||
|
} else if (p.project_ids?.length) {
|
||||||
|
cond.push(
|
||||||
|
`d.project_id IN (${p.project_ids.map(() => "?").join(",")})`
|
||||||
|
);
|
||||||
|
params.push(...p.project_ids);
|
||||||
|
}
|
||||||
|
} else if (project_id) {
|
||||||
|
cond.push("d.project_id=?");
|
||||||
|
params.push(Number(project_id));
|
||||||
|
}
|
||||||
|
|
||||||
r.get('/documents/:id', requireAuth, async (req, res) => {
|
if (status) {
|
||||||
const row = await Doc.findByPk(Number(req.params.id));
|
cond.push("d.status=?");
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
params.push(status);
|
||||||
|
}
|
||||||
|
if (category) {
|
||||||
|
cond.push("d.category=?");
|
||||||
|
params.push(category);
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
cond.push("(LOWER(d.title) LIKE ? OR d.doc_no LIKE ?)");
|
||||||
|
params.push(`%${String(q).toLowerCase()}%`, `%${q}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
|
||||||
|
const [[{ cnt }]] = await sql.query(
|
||||||
|
`SELECT COUNT(*) AS cnt FROM documents d ${where}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
`SELECT d.* FROM documents d ${where} ORDER BY d.created_at DESC LIMIT ? OFFSET ?`,
|
||||||
|
[...params, limit, offset]
|
||||||
|
);
|
||||||
|
res.json({ items: rows, total: cnt, page: Number(page), page_size: limit });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET
|
||||||
|
r.get("/:id", requirePerm("documents.view"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const [[row]] = await sql.query(
|
||||||
|
"SELECT * FROM documents WHERE document_id=?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const p = req.principal;
|
||||||
|
if (!p.is_superadmin && !p.inProject(row.project_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
res.json(row);
|
res.json(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
r.post('/documents', requireAuth, enrichPermissions(), requireProjectMembershipFromBody(), enrichPermissions(), requirePerm('document:create'), async (req, res) => {
|
// CREATE
|
||||||
const { project_id, doc_no, title, category, status } = req.body || {};
|
r.post(
|
||||||
if (!project_id || !doc_no) return res.status(400).json({ error: 'project_id and doc_no required' });
|
"/",
|
||||||
const created = await Doc.create({ project_id, doc_no, title, category, status, created_by: req.user?.user_id });
|
requirePerm("documents.manage", { projectParam: "project_id" }),
|
||||||
res.status(201).json({ document_id: created.document_id });
|
async (req, res) => {
|
||||||
});
|
const { project_id, doc_no, title, category, status } = req.body || {};
|
||||||
|
if (!project_id || !doc_no)
|
||||||
|
return res.status(400).json({ error: "project_id and doc_no required" });
|
||||||
|
const [rs] = await sql.query(
|
||||||
|
`INSERT INTO documents (project_id, doc_no, title, category, status, created_by)
|
||||||
|
VALUES (?,?,?,?,?,?)`,
|
||||||
|
[
|
||||||
|
project_id,
|
||||||
|
doc_no,
|
||||||
|
title || null,
|
||||||
|
category || null,
|
||||||
|
status || null,
|
||||||
|
req.principal.user_id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
res.status(201).json({ document_id: rs.insertId });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// UPDATE
|
||||||
|
r.patch("/:id", requirePerm("documents.manage"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const [[row]] = await sql.query(
|
||||||
|
"SELECT * FROM documents WHERE document_id=?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const p = req.principal;
|
||||||
|
if (!p.is_superadmin && !p.inProject(row.project_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
|
||||||
r.patch('/documents/:id', requireAuth, enrichPermissions(), requireProjectMembershipByRecord({ modelLoader: (s)=> (await import('../db/models/Document.js')).default(s) }), enrichPermissions(), requirePerm('document:update'), async (req, res) => {
|
|
||||||
const row = await Doc.findByPk(Number(req.params.id));
|
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
|
||||||
const { title, category, status } = req.body || {};
|
const { title, category, status } = req.body || {};
|
||||||
if (title !== undefined) row.title = title;
|
await sql.query(
|
||||||
if (category !== undefined) row.category = category;
|
"UPDATE documents SET title=?, category=?, status=?, updated_by=? WHERE document_id=?",
|
||||||
if (status !== undefined) row.status = status;
|
[
|
||||||
row.updated_by = req.user?.user_id;
|
title ?? row.title,
|
||||||
await row.save();
|
category ?? row.category,
|
||||||
|
status ?? row.status,
|
||||||
|
req.principal.user_id,
|
||||||
|
id,
|
||||||
|
]
|
||||||
|
);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
r.delete('/documents/:id', requireAuth, enrichPermissions(), requireProjectMembershipByRecord({ modelLoader: (s)=> (await import('../db/models/Document.js')).default(s) }), enrichPermissions(), requirePerm('document:delete'), async (req, res) => {
|
// DELETE
|
||||||
const row = await Doc.findByPk(Number(req.params.id));
|
r.delete("/:id", requirePerm("documents.manage"), async (req, res) => {
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
const id = Number(req.params.id);
|
||||||
await row.destroy();
|
const [[row]] = await sql.query(
|
||||||
|
"SELECT * FROM documents WHERE document_id=?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const p = req.principal;
|
||||||
|
if (!p.is_superadmin && !p.inProject(row.project_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
await sql.query("DELETE FROM documents WHERE document_id=?", [id]);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,85 +1,120 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/drawings.js
|
||||||
import sql from '../db/index.js';
|
import { Router } from "express";
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
import sql from "../db/index.js";
|
||||||
import { buildScopeWhere, ownerResolvers } from '../utils/scope.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
import PERM from '../config/permissions.js';
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const OWN = ownerResolvers(sql, 'drawings', 'id');
|
|
||||||
|
|
||||||
// LIST
|
// LIST
|
||||||
r.get('/',
|
r.get(
|
||||||
requirePerm('drawing.read', { scope: 'global' }),
|
"/",
|
||||||
|
requirePerm("drawings.view", { projectParam: "project_id" }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const { project_id, org_id, code, q, limit = 50, offset = 0 } = req.query;
|
const { project_id, org_id, code, q, limit = 50, offset = 0 } = req.query;
|
||||||
|
const p = req.principal;
|
||||||
|
const params = [];
|
||||||
|
const cond = [];
|
||||||
|
|
||||||
const base = buildScopeWhere(req.principal, {
|
if (!p.is_superadmin) {
|
||||||
tableAlias: 'd', orgColumn: 'd.org_id', projectColumn: 'd.project_id',
|
if (project_id) {
|
||||||
permCode: 'drawing.read', preferProject: true,
|
if (!p.inProject(Number(project_id)))
|
||||||
});
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
cond.push("d.project_id=?");
|
||||||
|
params.push(Number(project_id));
|
||||||
|
} else if (p.project_ids?.length) {
|
||||||
|
cond.push(
|
||||||
|
`d.project_id IN (${p.project_ids.map(() => "?").join(",")})`
|
||||||
|
);
|
||||||
|
params.push(...p.project_ids);
|
||||||
|
}
|
||||||
|
} else if (project_id) {
|
||||||
|
cond.push("d.project_id=?");
|
||||||
|
params.push(Number(project_id));
|
||||||
|
}
|
||||||
|
|
||||||
const extra = [];
|
if (org_id) {
|
||||||
const params = { ...base.params, limit: Number(limit), offset: Number(offset) };
|
cond.push("d.org_id=?");
|
||||||
if (project_id) { extra.push('d.project_id = :project_id'); params.project_id = Number(project_id); }
|
params.push(Number(org_id));
|
||||||
if (org_id) { extra.push('d.org_id = :org_id'); params.org_id = Number(org_id); }
|
}
|
||||||
if (code) { extra.push('d.dwg_code = :code'); params.code = code; }
|
if (code) {
|
||||||
if (q) { extra.push('(d.dwg_no LIKE :q OR d.title LIKE :q)'); params.q = `%${q}%`; }
|
cond.push("d.dwg_code=?");
|
||||||
|
params.push(code);
|
||||||
const where = [base.where, ...extra].filter(Boolean).join(' AND ');
|
}
|
||||||
|
if (q) {
|
||||||
|
cond.push("(d.dwg_no LIKE ? OR d.title LIKE ?)");
|
||||||
|
params.push(`%${q}%`, `%${q}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
|
||||||
const [rows] = await sql.query(
|
const [rows] = await sql.query(
|
||||||
`SELECT d.* FROM drawings d WHERE ${where}
|
`SELECT d.* FROM drawings d ${where} ORDER BY d.id DESC LIMIT ? OFFSET ?`,
|
||||||
ORDER BY d.id DESC LIMIT :limit OFFSET :offset`,
|
[...params, Number(limit), Number(offset)]
|
||||||
params
|
|
||||||
);
|
);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET
|
// GET
|
||||||
r.get('/:id',
|
r.get("/:id", requirePerm("drawings.view"), async (req, res) => {
|
||||||
requirePerm('drawing.read', { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
const id = Number(req.params.id);
|
||||||
async (req, res) => {
|
const [[row]] = await sql.query("SELECT * FROM drawings WHERE id=?", [id]);
|
||||||
const id = Number(req.params.id);
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
const [[row]] = await sql.query('SELECT * FROM drawings WHERE id=?', [id]);
|
const p = req.principal;
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
if (!p.is_superadmin && !p.inProject(row.project_id))
|
||||||
res.json(row);
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
}
|
res.json(row);
|
||||||
);
|
});
|
||||||
|
|
||||||
// CREATE
|
// CREATE
|
||||||
r.post('/',
|
r.post(
|
||||||
requirePerm('drawing.create', { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
|
"/",
|
||||||
|
requirePerm("drawings.upload", { projectParam: "project_id" }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const { org_id, project_id, dwg_no, dwg_code, title } = req.body;
|
const { org_id, project_id, dwg_no, dwg_code, title } = req.body || {};
|
||||||
|
if (!project_id || !dwg_no)
|
||||||
|
return res.status(400).json({ error: "project_id and dwg_no required" });
|
||||||
const [rs] = await sql.query(
|
const [rs] = await sql.query(
|
||||||
`INSERT INTO drawings (org_id, project_id, dwg_no, dwg_code, title, created_by)
|
`INSERT INTO drawings (org_id, project_id, dwg_no, dwg_code, title, created_by)
|
||||||
VALUES (?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?)`,
|
||||||
[org_id, project_id, dwg_no, dwg_code, title, req.principal.userId]
|
[
|
||||||
|
org_id || null,
|
||||||
|
project_id,
|
||||||
|
dwg_no,
|
||||||
|
dwg_code || null,
|
||||||
|
title || null,
|
||||||
|
req.principal.user_id,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
res.json({ id: rs.insertId });
|
res.json({ id: rs.insertId });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// UPDATE
|
// UPDATE (ใช้สิทธิ์ drawings.upload)
|
||||||
r.put('/:id',
|
r.put("/:id", requirePerm("drawings.upload"), async (req, res) => {
|
||||||
requirePerm('drawing.update', { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
const id = Number(req.params.id);
|
||||||
async (req, res) => {
|
const [[row]] = await sql.query("SELECT * FROM drawings WHERE id=?", [id]);
|
||||||
const id = Number(req.params.id);
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
const { title } = req.body;
|
const p = req.principal;
|
||||||
await sql.query('UPDATE drawings SET title=? WHERE id=?', [title, id]);
|
if (!p.is_superadmin && !p.inProject(row.project_id))
|
||||||
res.json({ ok: 1 });
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
}
|
const { title } = req.body || {};
|
||||||
);
|
await sql.query("UPDATE drawings SET title=? WHERE id=?", [
|
||||||
|
title ?? row.title,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE
|
// DELETE
|
||||||
r.delete('/:id',
|
r.delete("/:id", requirePerm("drawings.delete"), async (req, res) => {
|
||||||
requirePerm('drawing.delete', { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
const id = Number(req.params.id);
|
||||||
async (req, res) => {
|
const [[row]] = await sql.query("SELECT * FROM drawings WHERE id=?", [id]);
|
||||||
const id = Number(req.params.id);
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
await sql.query('DELETE FROM drawings WHERE id=?', [id]);
|
const p = req.principal;
|
||||||
res.json({ ok: 1 });
|
if (!p.is_superadmin && !p.inProject(row.project_id))
|
||||||
}
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
);
|
await sql.query("DELETE FROM drawings WHERE id=?", [id]);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
|
|||||||
@@ -1,91 +1,154 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/files_extras.js
|
||||||
import fs from 'fs';
|
// NOTE: generic file actions – ผูกสิทธิ์ตามโมดูลปลายทาง และบังคับ ABAC จาก project_id ของเรคคอร์ด
|
||||||
import path from 'path';
|
import { Router } from "express";
|
||||||
import jwt from 'jsonwebtoken';
|
import fs from "node:fs";
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import sql from "../db/index.js";
|
||||||
import { enrichPermissions } from '../middleware/permissions.js';
|
|
||||||
import { requireRole } from '../middleware/rbac.js';
|
|
||||||
import { requirePerm } from '../middleware/permGuard.js';
|
|
||||||
import { sequelize } from '../db/sequelize.js';
|
|
||||||
import FileModel from '../db/models/FileObject.js';
|
|
||||||
import { config } from '../config.js';
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const Files = FileModel(sequelize);
|
|
||||||
|
|
||||||
async function projectForFile(rec) {
|
async function projectForFile(rec) {
|
||||||
const mod = rec.module; const refId = rec.ref_id;
|
// โปรเจ็คของไฟล์อิงโมดูล/ตารางอ้างอิง
|
||||||
switch (mod) {
|
switch (rec.module) {
|
||||||
case 'rfa': { const M = (await import('../db/models/RFA.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; }
|
case "rfa": {
|
||||||
case 'correspondence': { const M = (await import('../db/models/Correspondence.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; }
|
const [[row]] = await sql.query(
|
||||||
case 'drawing': { const M = (await import('../db/models/Drawing.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; }
|
"SELECT project_id FROM rfas WHERE id=?",
|
||||||
case 'document': { const M = (await import('../db/models/Document.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; }
|
[rec.ref_id]
|
||||||
case 'transmittal': { const M = (await import('../db/models/Transmittal.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; }
|
);
|
||||||
default: return null;
|
return row?.project_id || null;
|
||||||
|
}
|
||||||
|
case "correspondence": {
|
||||||
|
const [[row]] = await sql.query(
|
||||||
|
"SELECT project_id FROM correspondences WHERE id=?",
|
||||||
|
[rec.ref_id]
|
||||||
|
);
|
||||||
|
return row?.project_id || null;
|
||||||
|
}
|
||||||
|
case "drawing": {
|
||||||
|
const [[row]] = await sql.query(
|
||||||
|
"SELECT project_id FROM drawings WHERE id=?",
|
||||||
|
[rec.ref_id]
|
||||||
|
);
|
||||||
|
return row?.project_id || null;
|
||||||
|
}
|
||||||
|
case "document": {
|
||||||
|
const [[row]] = await sql.query(
|
||||||
|
"SELECT project_id FROM documents WHERE document_id=?",
|
||||||
|
[rec.ref_id]
|
||||||
|
);
|
||||||
|
return row?.project_id || null;
|
||||||
|
}
|
||||||
|
case "transmittal": {
|
||||||
|
const [[row]] = await sql.query(
|
||||||
|
"SELECT project_id FROM transmittals WHERE id=?",
|
||||||
|
[rec.ref_id]
|
||||||
|
);
|
||||||
|
return row?.project_id || null;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function permForFile(rec, action /* 'read'|'update'|'delete' */) {
|
||||||
|
// map เป็น permission ของโมดูลจริง
|
||||||
|
const m = rec.module;
|
||||||
|
if (m === "document")
|
||||||
|
return action === "read" ? "documents.view" : "documents.manage";
|
||||||
|
if (m === "drawing")
|
||||||
|
return action === "read"
|
||||||
|
? "drawings.view"
|
||||||
|
: action === "delete"
|
||||||
|
? "drawings.delete"
|
||||||
|
: "drawings.upload";
|
||||||
|
if (m === "correspondence")
|
||||||
|
return action === "read" ? "corr.view" : "corr.manage";
|
||||||
|
if (m === "rfa") return action === "read" ? "rfas.view" : "rfas.respond";
|
||||||
|
if (m === "transmittal") return "transmittals.manage";
|
||||||
|
return "documents.manage"; // fallback
|
||||||
|
}
|
||||||
|
|
||||||
// HEAD meta only
|
// HEAD meta
|
||||||
r.head('/files/:file_id', requireAuth, async (req, res) => {
|
r.head("/files/:file_id", async (req, res) => {
|
||||||
const rec = await Files.findByPk(Number(req.params.file_id));
|
const id = Number(req.params.file_id);
|
||||||
|
const [[rec]] = await sql.query("SELECT * FROM files WHERE file_id=?", [id]);
|
||||||
if (!rec) return res.status(404).end();
|
if (!rec) return res.status(404).end();
|
||||||
res.setHeader('Content-Type', rec.mime || 'application/octet-stream');
|
res.setHeader("Content-Type", rec.mime || "application/octet-stream");
|
||||||
res.setHeader('Content-Length', String(rec.size || 0));
|
res.setHeader("Content-Length", String(rec.size || 0));
|
||||||
res.status(200).end();
|
res.status(200).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
// delete (soft delete is recommended; here we do physical delete + record delete)
|
// DELETE
|
||||||
r.delete('/files/:file_id', requireAuth, enrichPermissions(), requirePerm('file:delete'), async (req, res) => {
|
r.delete("/files/:file_id", async (req, res) => {
|
||||||
const rec = await Files.findByPk(Number(req.params.file_id));
|
const id = Number(req.params.file_id);
|
||||||
if (!rec) return res.status(404).json({ error: 'Not found' });
|
const [[rec]] = await sql.query("SELECT * FROM files WHERE file_id=?", [id]);
|
||||||
|
if (!rec) return res.status(404).json({ error: "Not found" });
|
||||||
|
|
||||||
|
const p = req.principal;
|
||||||
|
if (!p) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
|
|
||||||
const pid = await projectForFile(rec);
|
const pid = await projectForFile(rec);
|
||||||
const roles = req.user?.roles || [];
|
if (!p.is_superadmin) {
|
||||||
const isAdmin = roles.includes('Admin');
|
if (!pid || !p.inProject(pid))
|
||||||
if (!isAdmin) {
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
const { getUserProjectIds } = await import('../middleware/abac.js');
|
const need = permForFile(rec, "delete");
|
||||||
const memberProjects = await getUserProjectIds(req.user?.user_id);
|
if (!p.can?.(need))
|
||||||
if (!memberProjects.includes(pid)) return res.status(403).json({ error: 'Forbidden: not a project member' });
|
return res.status(403).json({ error: "FORBIDDEN", need });
|
||||||
}
|
}
|
||||||
try { fs.unlinkSync(rec.disk_path); } catch {}
|
|
||||||
await rec.destroy();
|
try {
|
||||||
|
if (rec.disk_path) fs.unlinkSync(rec.disk_path);
|
||||||
|
} catch {}
|
||||||
|
await sql.query("DELETE FROM files WHERE file_id=?", [id]);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// rename (meta only - keep disk file name)
|
// RENAME (meta only)
|
||||||
r.post('/files/:file_id/rename', requireAuth, enrichPermissions(), requirePerm('file:update'), async (req, res) => {
|
r.post("/files/:file_id/rename", async (req, res) => {
|
||||||
const rec = await Files.findByPk(Number(req.params.file_id));
|
const id = Number(req.params.file_id);
|
||||||
if (!rec) return res.status(404).json({ error: 'Not found' });
|
const [[rec]] = await sql.query("SELECT * FROM files WHERE file_id=?", [id]);
|
||||||
|
if (!rec) return res.status(404).json({ error: "Not found" });
|
||||||
|
|
||||||
|
const p = req.principal;
|
||||||
|
if (!p) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
|
|
||||||
const pid = await projectForFile(rec);
|
const pid = await projectForFile(rec);
|
||||||
const roles = req.user?.roles || [];
|
if (!p.is_superadmin) {
|
||||||
const isAdmin = roles.includes('Admin');
|
if (!pid || !p.inProject(pid))
|
||||||
if (!isAdmin) {
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
const { getUserProjectIds } = await import('../middleware/abac.js');
|
const need = permForFile(rec, "update");
|
||||||
const memberProjects = await getUserProjectIds(req.user?.user_id);
|
if (!p.can?.(need))
|
||||||
if (!memberProjects.includes(pid)) return res.status(403).json({ error: 'Forbidden: not a project member' });
|
return res.status(403).json({ error: "FORBIDDEN", need });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { orig_name } = req.body || {};
|
const { orig_name } = req.body || {};
|
||||||
if (!orig_name) return res.status(400).json({ error: 'orig_name required' });
|
if (!orig_name) return res.status(400).json({ error: "orig_name required" });
|
||||||
rec.orig_name = orig_name;
|
await sql.query("UPDATE files SET orig_name=? WHERE file_id=?", [
|
||||||
await rec.save();
|
orig_name,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// refresh signed download url
|
// refresh signed download URL – ปกติใช้ signed URL service ภายนอก; ที่นี่คืน URL ภายในเป็นตัวอย่าง
|
||||||
r.post('/files/:file_id/refresh-url', requireAuth, async (req, res) => {
|
r.post("/files/:file_id/refresh-url", async (req, res) => {
|
||||||
const rec = await Files.findByPk(Number(req.params.file_id));
|
const id = Number(req.params.file_id);
|
||||||
if (!rec) return res.status(404).json({ error: 'Not found' });
|
const [[rec]] = await sql.query("SELECT * FROM files WHERE file_id=?", [id]);
|
||||||
|
if (!rec) return res.status(404).json({ error: "Not found" });
|
||||||
|
|
||||||
|
const p = req.principal;
|
||||||
|
if (!p) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
|
|
||||||
const pid = await projectForFile(rec);
|
const pid = await projectForFile(rec);
|
||||||
const roles = req.user?.roles || [];
|
if (!p.is_superadmin) {
|
||||||
const isAdmin = roles.includes('Admin');
|
if (!pid || !p.inProject(pid))
|
||||||
if (!isAdmin) {
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
const { getUserProjectIds } = await import('../middleware/abac.js');
|
const need = permForFile(rec, "read");
|
||||||
const memberProjects = await getUserProjectIds(req.user?.user_id);
|
if (!p.can?.(need))
|
||||||
if (!memberProjects.includes(pid)) return res.status(403).json({ error: 'Forbidden: not a project member' });
|
return res.status(403).json({ error: "FORBIDDEN", need });
|
||||||
}
|
}
|
||||||
|
|
||||||
const expSec = Number(process.env.FILE_URL_EXPIRES || 600);
|
const expSec = Number(process.env.FILE_URL_EXPIRES || 600);
|
||||||
const token = jwt.sign({ file_id: rec.file_id }, config.JWT.SECRET, { expiresIn: expSec });
|
const download_url = `/api/files/${rec.file_id}?token=internal-placeholder&exp=${expSec}`;
|
||||||
const download_url = `/api/v1/files/${rec.file_id}?token=${token}`;
|
|
||||||
res.json({ download_url, expires_in: expSec });
|
res.json({ download_url, expires_in: expSec });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/health.js
|
||||||
import { sequelize } from '../db/sequelize.js';
|
import { Router } from "express";
|
||||||
|
import sql from "../db/index.js";
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
r.get('/health', async (_req, res) => {
|
|
||||||
|
// /api/health — ไม่ต้องใช้สิทธิ์
|
||||||
|
r.get("/health", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
await sequelize.query('SELECT 1 AS ok');
|
const [[{ now }]] = await sql.query("SELECT NOW() AS now");
|
||||||
res.status(200).json({ ok: true, db: 'up' });
|
res.status(200).json({ ok: true, db: "up", now });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ ok: false, db: 'down', error: String(e) });
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ ok: false, db: "down", error: String(e?.message || e) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
Volume in drive S is Container
|
|
||||||
Volume Serial Number is 1F5F-1DEB
|
|
||||||
|
|
||||||
Directory of S:\backend\src\routes
|
|
||||||
|
|
||||||
11 Sep 25 16:34 <DIR> .
|
|
||||||
11 Sep 25 16:33 <DIR> ..
|
|
||||||
10 Sep 25 16:42 1,713 auth.js
|
|
||||||
11 Sep 25 16:20 1,097 users.js
|
|
||||||
11 Sep 25 16:20 2,624 correspondences.js
|
|
||||||
08 Sep 25 08:58 1,306 rfa.js
|
|
||||||
11 Sep 25 16:20 2,671 transmittals.js
|
|
||||||
08 Sep 25 08:58 2,448 technicaldocs.js
|
|
||||||
08 Sep 25 08:58 2,410 contractdwg.js
|
|
||||||
08 Sep 25 08:46 2,234 admin.js
|
|
||||||
10 Sep 25 16:42 359 health.js
|
|
||||||
10 Sep 25 17:35 501 auth_extras.js
|
|
||||||
10 Sep 25 18:18 2,980 documents.js
|
|
||||||
11 Sep 25 16:20 2,848 drawings.js
|
|
||||||
10 Sep 25 18:18 4,514 files_extras.js
|
|
||||||
10 Sep 25 17:35 1,222 lookups.js
|
|
||||||
10 Sep 25 18:18 4,285 maps.js
|
|
||||||
10 Sep 25 17:35 630 module_files.js
|
|
||||||
11 Sep 25 16:23 5,182 mvp.js
|
|
||||||
10 Sep 25 17:35 729 ops.js
|
|
||||||
11 Sep 25 16:20 1,197 organizations.js
|
|
||||||
11 Sep 25 16:20 2,266 projects.js
|
|
||||||
10 Sep 25 17:42 3,604 rbac_admin.js
|
|
||||||
11 Sep 25 16:20 2,826 rfas.js
|
|
||||||
10 Sep 25 17:53 2,376 subcategories.js
|
|
||||||
11 Sep 25 16:20 2,090 uploads.js
|
|
||||||
10 Sep 25 17:35 2,320 users_extras.js
|
|
||||||
10 Sep 25 18:18 4,766 views.js
|
|
||||||
11 Sep 25 16:20 1,199 volumes.js
|
|
||||||
11 Sep 25 16:20 479 permissions.js
|
|
||||||
11 Sep 25 16:20 2,751 contracts.js
|
|
||||||
11 Sep 25 16:20 2,630 contract_dwg.js
|
|
||||||
11 Sep 25 16:20 1,782 categories.js
|
|
||||||
11 Sep 25 16:34 148 list.txt
|
|
||||||
32 File(s) 70,187 bytes
|
|
||||||
2 Dir(s) 3,725,382,303,744 bytes free
|
|
||||||
@@ -1,120 +1,125 @@
|
|||||||
// src/routes/lookup.js (ESM)
|
// FILE: backend/src/routes/lookup.js
|
||||||
import { Router } from 'express';
|
import { Router } from "express";
|
||||||
import sql from '../db/index.js';
|
import sql from "../db/index.js";
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
import { buildScopeWhere } from '../utils/scope.js';
|
|
||||||
import PERM from '../config/permissions.js';
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
|
|
||||||
/**
|
|
||||||
* ช่วยอ่าน query pick (คั่นด้วย comma)
|
|
||||||
*/
|
|
||||||
function parsePick(qs) {
|
|
||||||
if (!qs) return null;
|
|
||||||
return String(qs)
|
|
||||||
.split(',')
|
|
||||||
.map(s => s.trim().toLowerCase())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/lookup?pick=org,project,category,subcategory,volume,permission
|
// GET /api/lookup?pick=org,project,category,subcategory,volume,permission
|
||||||
r.get('/',
|
r.get("/", async (req, res) => {
|
||||||
// ต้องเป็นผู้ใช้ที่ยืนยันตัวตนแล้ว (middleware authJwt+loadPrincipal อยู่ใน /api)
|
const picks = new Set(
|
||||||
// ไม่ require permission เดียว เพราะเราจะเช็คทีละชุดด้านล่าง
|
String(
|
||||||
async (req, res) => {
|
req.query.pick || "org,project,category,subcategory,volume,permission"
|
||||||
const pick = new Set(parsePick(req.query.pick) || [
|
)
|
||||||
'org', 'project', 'category', 'subcategory', 'volume', 'permission'
|
.split(",")
|
||||||
]);
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
const result = {};
|
const out = {};
|
||||||
|
|
||||||
// 1) Organizations (scoped list) — require organization.read
|
// Organizations — GLOBAL (อ่านได้ด้วย organizations.view)
|
||||||
if (pick.has('org')) {
|
if (picks.has("org")) {
|
||||||
// มีสิทธิ์ถึงจะดึง
|
try {
|
||||||
const canOrg = req.principal.isSuperAdmin || req.principal.perms.has(PERM.organization.read);
|
// มี perm ไหม? (GLOBAL)
|
||||||
if (canOrg) {
|
const ok =
|
||||||
const { where, params } = buildScopeWhere(req.principal, {
|
req.principal?.is_superadmin ||
|
||||||
tableAlias: 'o',
|
req.principal?.permissions?.has?.("organizations.view");
|
||||||
orgColumn: 'o.org_id',
|
out.organizations = ok
|
||||||
projectColumn: 'NULL',
|
? (
|
||||||
permCode: PERM.organization.read,
|
await sql.query(
|
||||||
});
|
"SELECT org_id, org_name FROM organizations ORDER BY org_name"
|
||||||
const [rows] = await sql.query(`SELECT o.org_id, o.org_name FROM organizations o WHERE ${where} ORDER BY o.org_name`, params);
|
)
|
||||||
result.organizations = rows;
|
)[0]
|
||||||
} else {
|
: [];
|
||||||
result.organizations = [];
|
} catch {
|
||||||
}
|
out.organizations = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Projects (scoped list) — require project.read
|
|
||||||
if (pick.has('project')) {
|
|
||||||
const canPrj = req.principal.isSuperAdmin || req.principal.perms.has(PERM.project.read);
|
|
||||||
if (canPrj) {
|
|
||||||
const { where, params } = buildScopeWhere(req.principal, {
|
|
||||||
tableAlias: 'p',
|
|
||||||
orgColumn: 'p.org_id',
|
|
||||||
projectColumn: 'p.project_id',
|
|
||||||
permCode: PERM.project.read,
|
|
||||||
preferProject: true,
|
|
||||||
});
|
|
||||||
const [rows] = await sql.query(
|
|
||||||
`SELECT p.project_id, p.org_id, p.project_code, p.project_name
|
|
||||||
FROM projects p WHERE ${where} ORDER BY p.project_name`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
result.projects = rows;
|
|
||||||
} else {
|
|
||||||
result.projects = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Categories (global master) — require category.read
|
|
||||||
if (pick.has('category')) {
|
|
||||||
const can = req.principal.isSuperAdmin || req.principal.perms.has(PERM.category.read);
|
|
||||||
if (can) {
|
|
||||||
const [rows] = await sql.query('SELECT cat_id, cat_code, cat_name FROM categories ORDER BY cat_name');
|
|
||||||
result.categories = rows;
|
|
||||||
} else {
|
|
||||||
result.categories = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Subcategories (global master) — require category.read
|
|
||||||
if (pick.has('subcategory')) {
|
|
||||||
const can = req.principal.isSuperAdmin || req.principal.perms.has(PERM.category.read);
|
|
||||||
if (can) {
|
|
||||||
const [rows] = await sql.query('SELECT sub_cat_id, cat_id, sub_cat_name FROM subcategories ORDER BY sub_cat_name');
|
|
||||||
result.subcategories = rows;
|
|
||||||
} else {
|
|
||||||
result.subcategories = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) Volumes (global master) — require volume.read
|
|
||||||
if (pick.has('volume')) {
|
|
||||||
const can = req.principal.isSuperAdmin || req.principal.perms.has(PERM.volume.read);
|
|
||||||
if (can) {
|
|
||||||
const [rows] = await sql.query('SELECT volume_id, volume_code, volume_name FROM volumes ORDER BY volume_code');
|
|
||||||
result.volumes = rows;
|
|
||||||
} else {
|
|
||||||
result.volumes = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6) Permissions (global master) — require permission.read
|
|
||||||
if (pick.has('permission')) {
|
|
||||||
const can = req.principal.isSuperAdmin || req.principal.perms.has(PERM.permission.read);
|
|
||||||
if (can) {
|
|
||||||
const [rows] = await sql.query('SELECT permission_id, permission_code, description FROM permissions ORDER BY permission_code');
|
|
||||||
result.permissions = rows;
|
|
||||||
} else {
|
|
||||||
result.permissions = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(result);
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
// Projects — ORG scope (projects.view)
|
||||||
|
if (picks.has("project")) {
|
||||||
|
try {
|
||||||
|
const ok =
|
||||||
|
req.principal?.is_superadmin ||
|
||||||
|
req.principal?.permissions?.has?.("projects.view");
|
||||||
|
if (!ok) out.projects = [];
|
||||||
|
else {
|
||||||
|
// จำกัดตาม org scope
|
||||||
|
const p = req.principal;
|
||||||
|
let rows = [];
|
||||||
|
if (p.is_superadmin) {
|
||||||
|
[rows] = await sql.query(
|
||||||
|
"SELECT project_id, org_id, project_code, project_name FROM projects ORDER BY project_name"
|
||||||
|
);
|
||||||
|
} else if (p.org_ids?.length) {
|
||||||
|
const inSql = p.org_ids.map(() => "?").join(",");
|
||||||
|
[rows] = await sql.query(
|
||||||
|
`SELECT project_id, org_id, project_code, project_name
|
||||||
|
FROM projects WHERE org_id IN (${inSql})
|
||||||
|
ORDER BY project_name`,
|
||||||
|
p.org_ids
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
rows = [];
|
||||||
|
}
|
||||||
|
out.projects = rows;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
out.projects = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories/Subcategories/Volumes — GLOBAL master (settings.manage ไม่จำเป็นสำหรับการอ่าน lookup)
|
||||||
|
if (picks.has("category")) {
|
||||||
|
try {
|
||||||
|
out.categories = (
|
||||||
|
await sql.query(
|
||||||
|
"SELECT cat_id, cat_code, cat_name FROM categories ORDER BY cat_name"
|
||||||
|
)
|
||||||
|
)[0];
|
||||||
|
} catch {
|
||||||
|
out.categories = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (picks.has("subcategory")) {
|
||||||
|
try {
|
||||||
|
out.subcategories = (
|
||||||
|
await sql.query(
|
||||||
|
"SELECT sub_cat_id, cat_id, sub_cat_name FROM subcategories ORDER BY sub_cat_name"
|
||||||
|
)
|
||||||
|
)[0];
|
||||||
|
} catch {
|
||||||
|
out.subcategories = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (picks.has("volume")) {
|
||||||
|
try {
|
||||||
|
out.volumes = (
|
||||||
|
await sql.query(
|
||||||
|
"SELECT volume_id, volume_code, volume_name FROM volumes ORDER BY volume_code"
|
||||||
|
)
|
||||||
|
)[0];
|
||||||
|
} catch {
|
||||||
|
out.volumes = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permissions — GLOBAL (settings.manage เท่านั้นที่ควรเห็นทั้งหมด)
|
||||||
|
if (picks.has("permission")) {
|
||||||
|
const ok =
|
||||||
|
req.principal?.is_superadmin ||
|
||||||
|
req.principal?.permissions?.has?.("settings.manage");
|
||||||
|
out.permissions = ok
|
||||||
|
? (
|
||||||
|
await sql.query(
|
||||||
|
"SELECT permission_id, perm_code AS permission_code, description FROM permissions ORDER BY perm_code"
|
||||||
|
)
|
||||||
|
)[0]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(out);
|
||||||
|
});
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
|
|||||||
@@ -1,83 +1,163 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/maps.js
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
// Map ความสัมพันธ์ระหว่าง RFA<->Drawing และ Correspondence<->Document
|
||||||
import { enrichPermissions } from '../middleware/permissions.js';
|
import { Router } from "express";
|
||||||
import { requireRole } from '../middleware/rbac.js';
|
import sql from "../db/index.js";
|
||||||
import { requirePerm } from '../middleware/permGuard.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
import { sequelize } from '../db/sequelize.js';
|
|
||||||
import RfaModel from '../db/models/RFA.js';
|
|
||||||
import DrawingModel from '../db/models/Drawing.js';
|
|
||||||
import RfaDrawMapModel from '../db/models/RfaDrawingMap.js';
|
|
||||||
import CorrModel from '../db/models/Correspondence.js';
|
|
||||||
import DocModel from '../db/models/Document.js';
|
|
||||||
import CorrDocMapModel from '../db/models/CorrDocumentMap.js';
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const RFA = RfaModel(sequelize);
|
|
||||||
const Drawing = DrawingModel(sequelize);
|
|
||||||
const RfaDraw = RfaDrawMapModel(sequelize);
|
|
||||||
const Corr = CorrModel(sequelize);
|
|
||||||
const Doc = DocModel(sequelize);
|
|
||||||
const CorrDoc = CorrDocMapModel(sequelize);
|
|
||||||
|
|
||||||
async function ensureRfaMembership(req, res) {
|
// ========= RFA <-> Drawing =========
|
||||||
const rfaId = Number(req.params.rfa_id);
|
// LIST
|
||||||
const row = await RFA.findByPk(rfaId);
|
r.get(
|
||||||
if (!row) { res.status(404).json({ error:'RFA not found' }); return false; }
|
"/maps/rfa/:rfa_id/drawings",
|
||||||
const roles = req.user?.roles || []; const isAdmin = roles.includes('Admin');
|
requirePerm("rfas.view", { projectParam: "project_id" }), // ABAC enforced เมื่อส่ง query project_id; ถ้าไม่ส่งเราจะตรวจจากเรคคอร์ด
|
||||||
if (isAdmin) return true;
|
async (req, res) => {
|
||||||
const { getUserProjectIds } = await import('../middleware/abac.js');
|
const rfa_id = Number(req.params.rfa_id);
|
||||||
const memberProjects = await getUserProjectIds(req.user?.user_id);
|
const [[rfa]] = await sql.query("SELECT project_id FROM rfas WHERE id=?", [
|
||||||
if (!memberProjects.includes(Number(row.project_id))) { res.status(403).json({ error:'Forbidden: not a project member' }); return false; }
|
rfa_id,
|
||||||
return true;
|
]);
|
||||||
}
|
if (!rfa) return res.status(404).json({ error: "RFA not found" });
|
||||||
|
if (
|
||||||
|
!req.principal.is_superadmin &&
|
||||||
|
!req.principal.inProject(rfa.project_id)
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
}
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
`SELECT m.* FROM rfa_drawing_map m WHERE m.rfa_id=? ORDER BY m.id DESC`,
|
||||||
|
[rfa_id]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
async function ensureCorrMembership(req, res) {
|
// ADD
|
||||||
const corrId = Number(req.params.corr_id);
|
r.post(
|
||||||
const row = await Corr.findByPk(corrId);
|
"/maps/rfa/:rfa_id/drawings/:drawing_id",
|
||||||
if (!row) { res.status(404).json({ error:'Correspondence not found' }); return false; }
|
requirePerm("rfas.respond", { projectParam: "project_id" }),
|
||||||
const roles = req.user?.roles || []; const isAdmin = roles.includes('Admin');
|
async (req, res) => {
|
||||||
if (isAdmin) return true;
|
const rfa_id = Number(req.params.rfa_id);
|
||||||
const { getUserProjectIds } = await import('../middleware/abac.js');
|
const drawing_id = Number(req.params.drawing_id);
|
||||||
const memberProjects = await getUserProjectIds(req.user?.user_id);
|
|
||||||
if (!memberProjects.includes(Number(row.project_id))) { res.status(403).json({ error:'Forbidden: not a project member' }); return false; }
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const [[rfa]] = await sql.query("SELECT project_id FROM rfas WHERE id=?", [
|
||||||
|
rfa_id,
|
||||||
|
]);
|
||||||
|
if (!rfa) return res.status(404).json({ error: "RFA not found" });
|
||||||
|
if (
|
||||||
|
!req.principal.is_superadmin &&
|
||||||
|
!req.principal.inProject(rfa.project_id)
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
}
|
||||||
|
await sql.query(
|
||||||
|
"INSERT IGNORE INTO rfa_drawing_map (rfa_id, drawing_id, created_by) VALUES (?,?,?)",
|
||||||
|
[rfa_id, drawing_id, req.principal.user_id]
|
||||||
|
);
|
||||||
|
res.status(201).json({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// RFA <-> Drawing
|
// REMOVE
|
||||||
r.get('/maps/rfa/:rfa_id/drawings', requireAuth, async (req, res) => {
|
r.delete(
|
||||||
const rows = await RfaDraw.findAll({ where: { rfa_id: Number(req.params.rfa_id) } });
|
"/maps/rfa/:rfa_id/drawings/:drawing_id",
|
||||||
res.json(rows);
|
requirePerm("rfas.respond"),
|
||||||
});
|
async (req, res) => {
|
||||||
r.post('/maps/rfa/:rfa_id/drawings/:drawing_id', requireAuth, enrichPermissions(), requirePerm('rfa:update'), async (req, res) => {
|
const rfa_id = Number(req.params.rfa_id);
|
||||||
if (!(await ensureRfaMembership(req, res))) return;
|
const drawing_id = Number(req.params.drawing_id);
|
||||||
const { rfa_id, drawing_id } = { rfa_id: Number(req.params.rfa_id), drawing_id: Number(req.params.drawing_id) };
|
const [[rfa]] = await sql.query("SELECT project_id FROM rfas WHERE id=?", [
|
||||||
await RfaDraw.create({ rfa_id, drawing_id });
|
rfa_id,
|
||||||
res.status(201).json({ ok: true });
|
]);
|
||||||
});
|
if (!rfa) return res.status(404).json({ error: "RFA not found" });
|
||||||
r.delete('/maps/rfa/:rfa_id/drawings/:drawing_id', requireAuth, enrichPermissions(), requirePerm('rfa:update'), async (req, res) => {
|
if (
|
||||||
if (!(await ensureRfaMembership(req, res))) return;
|
!req.principal.is_superadmin &&
|
||||||
const { rfa_id, drawing_id } = { rfa_id: Number(req.params.rfa_id), drawing_id: Number(req.params.drawing_id) };
|
!req.principal.inProject(rfa.project_id)
|
||||||
const count = await RfaDraw.destroy({ where: { rfa_id, drawing_id } });
|
) {
|
||||||
res.json({ ok: count > 0 });
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
});
|
}
|
||||||
|
const [rs] = await sql.query(
|
||||||
|
"DELETE FROM rfa_drawing_map WHERE rfa_id=? AND drawing_id=?",
|
||||||
|
[rfa_id, drawing_id]
|
||||||
|
);
|
||||||
|
res.json({ ok: rs.affectedRows > 0 });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Correspondence <-> Document
|
// ========= Correspondence <-> Document =========
|
||||||
r.get('/maps/correspondence/:corr_id/documents', requireAuth, async (req, res) => {
|
r.get(
|
||||||
const rows = await CorrDoc.findAll({ where: { correspondence_id: Number(req.params.corr_id) } });
|
"/maps/correspondence/:corr_id/documents",
|
||||||
res.json(rows);
|
requirePerm("corr.view", { projectParam: "project_id" }),
|
||||||
});
|
async (req, res) => {
|
||||||
r.post('/maps/correspondence/:corr_id/documents/:doc_id', requireAuth, enrichPermissions(), requirePerm('correspondence:update'), async (req, res) => {
|
const corr_id = Number(req.params.corr_id);
|
||||||
if (!(await ensureCorrMembership(req, res))) return;
|
const [[corr]] = await sql.query(
|
||||||
const { corr_id, doc_id } = { corr_id: Number(req.params.corr_id), doc_id: Number(req.params.doc_id) };
|
"SELECT project_id FROM correspondences WHERE id=?",
|
||||||
await CorrDoc.create({ correspondence_id: corr_id, document_id: doc_id });
|
[corr_id]
|
||||||
res.status(201).json({ ok: true });
|
);
|
||||||
});
|
if (!corr)
|
||||||
r.delete('/maps/correspondence/:corr_id/documents/:doc_id', requireAuth, enrichPermissions(), requirePerm('correspondence:update'), async (req, res) => {
|
return res.status(404).json({ error: "Correspondence not found" });
|
||||||
if (!(await ensureCorrMembership(req, res))) return;
|
if (
|
||||||
const { corr_id, doc_id } = { corr_id: Number(req.params.corr_id), doc_id: Number(req.params.doc_id) };
|
!req.principal.is_superadmin &&
|
||||||
const count = await CorrDoc.destroy({ where: { correspondence_id: corr_id, document_id: doc_id } });
|
!req.principal.inProject(corr.project_id)
|
||||||
res.json({ ok: count > 0 });
|
) {
|
||||||
});
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
}
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
`SELECT m.* FROM corr_document_map m WHERE m.correspondence_id=? ORDER BY m.id DESC`,
|
||||||
|
[corr_id]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
r.post(
|
||||||
|
"/maps/correspondence/:corr_id/documents/:doc_id",
|
||||||
|
requirePerm("corr.manage", { projectParam: "project_id" }),
|
||||||
|
async (req, res) => {
|
||||||
|
const corr_id = Number(req.params.corr_id);
|
||||||
|
const doc_id = Number(req.params.doc_id);
|
||||||
|
const [[corr]] = await sql.query(
|
||||||
|
"SELECT project_id FROM correspondences WHERE id=?",
|
||||||
|
[corr_id]
|
||||||
|
);
|
||||||
|
if (!corr)
|
||||||
|
return res.status(404).json({ error: "Correspondence not found" });
|
||||||
|
if (
|
||||||
|
!req.principal.is_superadmin &&
|
||||||
|
!req.principal.inProject(corr.project_id)
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
}
|
||||||
|
await sql.query(
|
||||||
|
"INSERT IGNORE INTO corr_document_map (correspondence_id, document_id, created_by) VALUES (?,?,?)",
|
||||||
|
[corr_id, doc_id, req.principal.user_id]
|
||||||
|
);
|
||||||
|
res.status(201).json({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
r.delete(
|
||||||
|
"/maps/correspondence/:corr_id/documents/:doc_id",
|
||||||
|
requirePerm("corr.manage"),
|
||||||
|
async (req, res) => {
|
||||||
|
const corr_id = Number(req.params.corr_id);
|
||||||
|
const doc_id = Number(req.params.doc_id);
|
||||||
|
const [[corr]] = await sql.query(
|
||||||
|
"SELECT project_id FROM correspondences WHERE id=?",
|
||||||
|
[corr_id]
|
||||||
|
);
|
||||||
|
if (!corr)
|
||||||
|
return res.status(404).json({ error: "Correspondence not found" });
|
||||||
|
if (
|
||||||
|
!req.principal.is_superadmin &&
|
||||||
|
!req.principal.inProject(corr.project_id)
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
}
|
||||||
|
const [rs] = await sql.query(
|
||||||
|
"DELETE FROM corr_document_map WHERE correspondence_id=? AND document_id=?",
|
||||||
|
[corr_id, doc_id]
|
||||||
|
);
|
||||||
|
res.json({ ok: rs.affectedRows > 0 });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
|
|||||||
@@ -1,19 +1,96 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/module_files.js
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { Router } from "express";
|
||||||
import { sequelize } from '../db/sequelize.js';
|
import sql from "../db/index.js";
|
||||||
import FileModel from '../db/models/FileObject.js';
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const Files = FileModel(sequelize);
|
|
||||||
|
|
||||||
async function listBy(mod, ref_id) {
|
// อ่านไฟล์ของแต่ละโมดูล โดยเช็ค ABAC + permission จาก principal
|
||||||
return Files.findAll({ where: { module: mod, ref_id }, order:[['created_at','DESC']] });
|
function readPermFor(mod) {
|
||||||
|
switch (mod) {
|
||||||
|
case "rfa":
|
||||||
|
return "rfas.view";
|
||||||
|
case "correspondence":
|
||||||
|
return "corr.view";
|
||||||
|
case "drawing":
|
||||||
|
return "drawings.view";
|
||||||
|
case "document":
|
||||||
|
return "documents.view";
|
||||||
|
case "transmittal":
|
||||||
|
return "transmittals.manage"; // โมดูลนี้ seed เป็น manage
|
||||||
|
default:
|
||||||
|
return "documents.view";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function projectOf(mod, id) {
|
||||||
|
switch (mod) {
|
||||||
|
case "rfa": {
|
||||||
|
const [[row]] = await sql.query(
|
||||||
|
"SELECT project_id FROM rfas WHERE id=?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return row?.project_id || null;
|
||||||
|
}
|
||||||
|
case "correspondence": {
|
||||||
|
const [[row]] = await sql.query(
|
||||||
|
"SELECT project_id FROM correspondences WHERE id=?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return row?.project_id || null;
|
||||||
|
}
|
||||||
|
case "drawing": {
|
||||||
|
const [[row]] = await sql.query(
|
||||||
|
"SELECT project_id FROM drawings WHERE id=?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return row?.project_id || null;
|
||||||
|
}
|
||||||
|
case "document": {
|
||||||
|
const [[row]] = await sql.query(
|
||||||
|
"SELECT project_id FROM documents WHERE document_id=?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return row?.project_id || null;
|
||||||
|
}
|
||||||
|
case "transmittal": {
|
||||||
|
const [[row]] = await sql.query(
|
||||||
|
"SELECT project_id FROM transmittals WHERE id=?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return row?.project_id || null;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const mod of ['rfa','correspondence','drawing','document','transmittal']) {
|
// /:module(s)/:id/files
|
||||||
r.get(`/${mod}s/:id/files`, requireAuth, async (req, res) => {
|
for (const mod of [
|
||||||
const items = await listBy(mod, Number(req.params.id));
|
"rfa",
|
||||||
res.json(items);
|
"correspondence",
|
||||||
|
"drawing",
|
||||||
|
"document",
|
||||||
|
"transmittal",
|
||||||
|
]) {
|
||||||
|
r.get(`/${mod}s/:id/files`, async (req, res) => {
|
||||||
|
const ref_id = Number(req.params.id);
|
||||||
|
const p = req.principal;
|
||||||
|
if (!p) return res.status(401).json({ error: "Unauthenticated" });
|
||||||
|
|
||||||
|
const need = readPermFor(mod);
|
||||||
|
if (!(p.is_superadmin || p.permissions?.has?.(need))) {
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN", need });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pid = await projectOf(mod, ref_id);
|
||||||
|
if (!p.is_superadmin && (!pid || !p.inProject(pid))) {
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
`SELECT f.* FROM files f WHERE f.module=? AND f.ref_id=? ORDER BY f.file_id DESC`,
|
||||||
|
[mod, ref_id]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +1,115 @@
|
|||||||
// src/routes/map.js
|
// FILE: backend/src/routes/mvp.js
|
||||||
import { Router } from 'express';
|
// (generic entity maps — ใช้ ‘projects.view’ อ่าน และ ‘projects.manage’ เขียน/ลบ)
|
||||||
import sql from '../db/index.js';
|
import { Router } from "express";
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
import sql from "../db/index.js";
|
||||||
import { buildScopeWhere, ownerResolvers } from '../utils/scope.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
import PERM from '../config/permissions.js';
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const OWN = ownerResolvers(sql, 'entity_maps', 'id');
|
|
||||||
|
|
||||||
// LIST
|
// LIST — projects.view (ORG scope)
|
||||||
r.get('/',
|
r.get(
|
||||||
requirePerm(PERM.map.read, { scope: 'global' }),
|
"/",
|
||||||
|
requirePerm("projects.view", { orgParam: "org_id" }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const { project_id, org_id, module, src_type, dst_type, limit = 100, offset = 0 } = req.query;
|
const {
|
||||||
|
project_id,
|
||||||
|
org_id,
|
||||||
|
module,
|
||||||
|
src_type,
|
||||||
|
dst_type,
|
||||||
|
limit = 100,
|
||||||
|
offset = 0,
|
||||||
|
} = req.query;
|
||||||
|
const p = req.principal;
|
||||||
|
const params = [];
|
||||||
|
const cond = [];
|
||||||
|
|
||||||
const base = buildScopeWhere(req.principal, {
|
if (!p.is_superadmin) {
|
||||||
tableAlias: 'm',
|
if (org_id) {
|
||||||
orgColumn: 'm.org_id',
|
if (!p.inOrg(Number(org_id)))
|
||||||
projectColumn: 'm.project_id',
|
return res.status(403).json({ error: "FORBIDDEN_ORG" });
|
||||||
permCode: PERM.map.read,
|
cond.push("m.org_id=?");
|
||||||
preferProject: true,
|
params.push(Number(org_id));
|
||||||
});
|
} else if (p.org_ids?.length) {
|
||||||
|
cond.push(`m.org_id IN (${p.org_ids.map(() => "?").join(",")})`);
|
||||||
|
params.push(...p.org_ids);
|
||||||
|
}
|
||||||
|
} else if (org_id) {
|
||||||
|
cond.push("m.org_id=?");
|
||||||
|
params.push(Number(org_id));
|
||||||
|
}
|
||||||
|
|
||||||
const extra = [];
|
if (project_id) {
|
||||||
const params = { ...base.params, limit: Number(limit), offset: Number(offset) };
|
cond.push("m.project_id=?");
|
||||||
if (project_id) { extra.push('m.project_id = :project_id'); params.project_id = Number(project_id); }
|
params.push(Number(project_id));
|
||||||
if (org_id) { extra.push('m.org_id = :org_id'); params.org_id = Number(org_id); }
|
}
|
||||||
if (module) { extra.push('m.module = :module'); params.module = module; }
|
if (module) {
|
||||||
if (src_type) { extra.push('m.src_type = :src_type'); params.src_type = src_type; }
|
cond.push("m.module=?");
|
||||||
if (dst_type) { extra.push('m.dst_type = :dst_type'); params.dst_type = dst_type; }
|
params.push(module);
|
||||||
|
}
|
||||||
|
if (src_type) {
|
||||||
|
cond.push("m.src_type=?");
|
||||||
|
params.push(src_type);
|
||||||
|
}
|
||||||
|
if (dst_type) {
|
||||||
|
cond.push("m.dst_type=?");
|
||||||
|
params.push(dst_type);
|
||||||
|
}
|
||||||
|
|
||||||
const where = [base.where, ...extra].filter(Boolean).join(' AND ');
|
const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
|
||||||
const [rows] = await sql.query(
|
const [rows] = await sql.query(
|
||||||
`SELECT m.* FROM entity_maps m
|
`SELECT m.* FROM entity_maps m ${where} ORDER BY m.id DESC LIMIT ? OFFSET ?`,
|
||||||
WHERE ${where}
|
[...params, Number(limit), Number(offset)]
|
||||||
ORDER BY m.id DESC LIMIT :limit OFFSET :offset`,
|
|
||||||
params
|
|
||||||
);
|
);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// CREATE
|
// CREATE — projects.manage (ORG scope)
|
||||||
r.post('/',
|
r.post(
|
||||||
requirePerm(PERM.map.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
|
"/",
|
||||||
|
requirePerm("projects.manage", { orgParam: "org_id" }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const { org_id, project_id, module, src_type, src_id, dst_type, dst_id, remark } = req.body;
|
const {
|
||||||
|
org_id,
|
||||||
|
project_id,
|
||||||
|
module,
|
||||||
|
src_type,
|
||||||
|
src_id,
|
||||||
|
dst_type,
|
||||||
|
dst_id,
|
||||||
|
remark,
|
||||||
|
} = req.body || {};
|
||||||
|
if (!org_id || !project_id || !module)
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "org_id, project_id, module required" });
|
||||||
const [rs] = await sql.query(
|
const [rs] = await sql.query(
|
||||||
`INSERT INTO entity_maps (org_id, project_id, module, src_type, src_id, dst_type, dst_id, remark, created_by)
|
`INSERT INTO entity_maps (org_id, project_id, module, src_type, src_id, dst_type, dst_id, remark, created_by)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||||
[org_id, project_id, module, src_type, Number(src_id), dst_type, Number(dst_id), remark ?? null, req.principal.userId]
|
[
|
||||||
|
Number(org_id),
|
||||||
|
Number(project_id),
|
||||||
|
module,
|
||||||
|
src_type || null,
|
||||||
|
src_id ? Number(src_id) : null,
|
||||||
|
dst_type || null,
|
||||||
|
dst_id ? Number(dst_id) : null,
|
||||||
|
remark || null,
|
||||||
|
req.principal.user_id,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
res.json({ id: rs.insertId });
|
res.json({ id: rs.insertId });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// DELETE (by id)
|
// DELETE — projects.manage (ORG scope)
|
||||||
r.delete('/:id',
|
r.delete(
|
||||||
requirePerm(PERM.map.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
"/:id",
|
||||||
|
requirePerm("projects.manage", { orgParam: "org_id" }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
await sql.query('DELETE FROM entity_maps WHERE id=?', [id]);
|
await sql.query("DELETE FROM entity_maps WHERE id=?", [id]);
|
||||||
res.json({ ok: 1 });
|
res.json({ ok: 1 });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,28 +1,29 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/ops.js
|
||||||
import { sequelize } from '../db/sequelize.js';
|
import { Router } from "express";
|
||||||
import fs from 'fs';
|
import sql from "../db/index.js";
|
||||||
import path from 'path';
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
|
|
||||||
r.get('/ready', async (_req, res) => {
|
r.get("/ready", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
await sequelize.query('SELECT 1');
|
await sql.query("SELECT 1");
|
||||||
return res.json({ ready: true });
|
res.json({ ready: true });
|
||||||
} catch {
|
} catch {
|
||||||
return res.status(500).json({ ready: false });
|
res.status(500).json({ ready: false });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
r.get('/live', (_req, res) => res.json({ live: true }));
|
r.get("/live", (_req, res) => res.json({ live: true }));
|
||||||
|
|
||||||
r.get('/version', (_req, res) => {
|
r.get("/version", (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const pkgPath = path.resolve(process.cwd(), 'package.json');
|
const pkgPath = path.resolve(process.cwd(), "package.json");
|
||||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
||||||
res.json({ name: pkg.name, version: pkg.version });
|
res.json({ name: pkg.name, version: pkg.version });
|
||||||
} catch {
|
} catch {
|
||||||
res.json({ name: 'dms-backend', version: 'unknown' });
|
res.json({ name: "dms-backend", version: "unknown" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,52 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/organizations.js
|
||||||
import sql from '../db/index.js';
|
import { Router } from "express";
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
import sql from "../db/index.js";
|
||||||
import { buildScopeWhere } from '../utils/scope.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
import PERM from '../config/permissions.js';
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
|
|
||||||
// LIST (org) – ดูเฉพาะ org ใน scope
|
// LIST
|
||||||
r.get('/',
|
r.get("/", requirePerm("organizations.view"), async (_req, res) => {
|
||||||
requirePerm('organization.read', { scope: 'global' }),
|
const [rows] = await sql.query(
|
||||||
async (req, res) => {
|
"SELECT * FROM organizations ORDER BY org_name"
|
||||||
const { where, params } = buildScopeWhere(req.principal, {
|
);
|
||||||
tableAlias: 'o', orgColumn: 'o.org_id', projectColumn: 'NULL',
|
res.json(rows);
|
||||||
permCode: 'organization.read',
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const [rows] = await sql.query(
|
// GET
|
||||||
`SELECT o.* FROM organizations o WHERE ${where}`,
|
r.get("/:id", requirePerm("organizations.view"), async (req, res) => {
|
||||||
params
|
const id = Number(req.params.id);
|
||||||
);
|
const [[row]] = await sql.query(
|
||||||
res.json(rows);
|
"SELECT * FROM organizations WHERE org_id=?",
|
||||||
}
|
[id]
|
||||||
);
|
);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
res.json(row);
|
||||||
|
});
|
||||||
|
|
||||||
// GET by id
|
// CREATE / UPDATE / DELETE — settings.manage
|
||||||
r.get('/:id',
|
r.post("/", requirePerm("settings.manage"), async (req, res) => {
|
||||||
requirePerm('organization.read', {
|
const { org_name } = req.body || {};
|
||||||
scope: 'org',
|
if (!org_name) return res.status(400).json({ error: "org_name required" });
|
||||||
getOrgId: async req => Number(req.params.id),
|
const [rs] = await sql.query(
|
||||||
}),
|
"INSERT INTO organizations (org_name) VALUES (?)",
|
||||||
async (req, res) => {
|
[org_name]
|
||||||
const id = Number(req.params.id);
|
);
|
||||||
const [[row]] = await sql.query('SELECT * FROM organizations WHERE org_id=?', [id]);
|
res.status(201).json({ org_id: rs.insertId });
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
});
|
||||||
res.json(row);
|
r.put("/:id", requirePerm("settings.manage"), async (req, res) => {
|
||||||
}
|
const id = Number(req.params.id);
|
||||||
);
|
const { org_name } = req.body || {};
|
||||||
|
await sql.query("UPDATE organizations SET org_name=? WHERE org_id=?", [
|
||||||
|
org_name,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
});
|
||||||
|
r.delete("/:id", requirePerm("settings.manage"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
await sql.query("DELETE FROM organizations WHERE org_id=?", [id]);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
// CREATE/UPDATE/DELETE ตามสิทธิ์ของคุณ (optional)
|
|
||||||
export default r;
|
export default r;
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/permissions.js
|
||||||
import sql from '../db/index.js';
|
import { Router } from "express";
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
import sql from "../db/index.js";
|
||||||
import PERM from '../config/permissions.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
|
|
||||||
r.get('/',
|
// GLOBAL: settings.manage จึงเห็นได้ทั้งหมด
|
||||||
requirePerm('permission.read', { scope: 'global' }),
|
r.get("/", requirePerm("settings.manage"), async (_req, res) => {
|
||||||
async (req, res) => {
|
const [rows] = await sql.query(
|
||||||
const [rows] = await sql.query('SELECT permission_id, permission_code, description FROM permissions ORDER BY permission_code');
|
"SELECT permission_id, perm_code AS permission_code, scope_level, description FROM permissions ORDER BY perm_code"
|
||||||
res.json(rows);
|
);
|
||||||
}
|
res.json(rows);
|
||||||
);
|
});
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
|
|||||||
@@ -1,80 +1,122 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/projects.js
|
||||||
import sql from '../db/index.js';
|
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
import { Router } from "express";
|
||||||
import { buildScopeWhere } from '../utils/scope.js';
|
import sql from "../db/index.js";
|
||||||
import PERM from '../config/permissions.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
|
|
||||||
// LIST – จำกัดตาม org/project scope ของผู้ใช้
|
// LIST — ORG scope
|
||||||
r.get('/',
|
r.get(
|
||||||
requirePerm('project.read', { scope: 'global' }),
|
"/",
|
||||||
|
requirePerm("projects.view", { orgParam: "org_id" }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const { where, params } = buildScopeWhere(req.principal, {
|
const p = req.principal;
|
||||||
tableAlias: 'p', orgColumn: 'p.org_id', projectColumn: 'p.project_id',
|
const { org_id } = req.query;
|
||||||
permCode: 'project.read', preferProject: true,
|
const params = [];
|
||||||
});
|
const cond = [];
|
||||||
|
|
||||||
|
if (!p.is_superadmin) {
|
||||||
|
if (org_id) {
|
||||||
|
if (!p.inOrg(Number(org_id)))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_ORG" });
|
||||||
|
cond.push("p.org_id=?");
|
||||||
|
params.push(Number(org_id));
|
||||||
|
} else if (p.org_ids?.length) {
|
||||||
|
cond.push(`p.org_id IN (${p.org_ids.map(() => "?").join(",")})`);
|
||||||
|
params.push(...p.org_ids);
|
||||||
|
}
|
||||||
|
} else if (org_id) {
|
||||||
|
cond.push("p.org_id=?");
|
||||||
|
params.push(Number(org_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
|
||||||
const [rows] = await sql.query(
|
const [rows] = await sql.query(
|
||||||
`SELECT p.* FROM projects p WHERE ${where}`,
|
`SELECT p.* FROM projects p ${where} ORDER BY p.project_name`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET
|
// GET — PROJECT scope
|
||||||
r.get('/:id',
|
r.get(
|
||||||
requirePerm('project.read', {
|
"/:id",
|
||||||
scope: 'project',
|
requirePerm("projects.view", { orgParam: "org_id" }),
|
||||||
getProjectId: async req => Number(req.params.id),
|
|
||||||
}),
|
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const [[row]] = await sql.query('SELECT * FROM projects WHERE project_id=?', [id]);
|
const [[row]] = await sql.query(
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
"SELECT * FROM projects WHERE project_id=?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const p = req.principal;
|
||||||
|
if (!p.is_superadmin && !p.inOrg(row.org_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_ORG" });
|
||||||
res.json(row);
|
res.json(row);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// CREATE
|
// CREATE — ORG scope
|
||||||
r.post('/',
|
r.post(
|
||||||
requirePerm('project.create', {
|
"/",
|
||||||
scope: 'org',
|
requirePerm("projects.manage", { orgParam: "org_id" }),
|
||||||
getOrgId: async req => req.body?.org_id ?? null,
|
|
||||||
}),
|
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const { org_id, project_code, project_name } = req.body;
|
const { org_id, project_code, project_name } = req.body || {};
|
||||||
|
if (!org_id || !project_code || !project_name) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "org_id, project_code, project_name required" });
|
||||||
|
}
|
||||||
const [rs] = await sql.query(
|
const [rs] = await sql.query(
|
||||||
'INSERT INTO projects (org_id, project_code, project_name) VALUES (?,?,?)',
|
"INSERT INTO projects (org_id, project_code, project_name, created_by) VALUES (?,?,?,?)",
|
||||||
[org_id, project_code, project_name]
|
[Number(org_id), project_code, project_name, req.principal.user_id]
|
||||||
);
|
);
|
||||||
res.json({ project_id: rs.insertId });
|
res.status(201).json({ project_id: rs.insertId });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// UPDATE
|
// UPDATE — ORG scope
|
||||||
r.put('/:id',
|
r.put(
|
||||||
requirePerm('project.update', {
|
"/:id",
|
||||||
scope: 'project',
|
requirePerm("projects.manage", { orgParam: "org_id" }),
|
||||||
getProjectId: async req => Number(req.params.id),
|
|
||||||
}),
|
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const { project_name } = req.body;
|
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
await sql.query('UPDATE projects SET project_name=? WHERE project_id=?', [project_name, id]);
|
const [[row]] = await sql.query(
|
||||||
|
"SELECT * FROM projects WHERE project_id=?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const p = req.principal;
|
||||||
|
if (!p.is_superadmin && !p.inOrg(row.org_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_ORG" });
|
||||||
|
|
||||||
|
const { project_name } = req.body || {};
|
||||||
|
await sql.query(
|
||||||
|
"UPDATE projects SET project_name=?, updated_by=? WHERE project_id=?",
|
||||||
|
[project_name ?? row.project_name, req.principal.user_id, id]
|
||||||
|
);
|
||||||
res.json({ ok: 1 });
|
res.json({ ok: 1 });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// DELETE
|
// DELETE — ORG scope
|
||||||
r.delete('/:id',
|
r.delete(
|
||||||
requirePerm('project.delete', {
|
"/:id",
|
||||||
scope: 'project',
|
requirePerm("projects.manage", { orgParam: "org_id" }),
|
||||||
getProjectId: async req => Number(req.params.id),
|
|
||||||
}),
|
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
await sql.query('DELETE FROM projects WHERE project_id=?', [id]);
|
const [[row]] = await sql.query(
|
||||||
|
"SELECT * FROM projects WHERE project_id=?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const p = req.principal;
|
||||||
|
if (!p.is_superadmin && !p.inOrg(row.org_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_ORG" });
|
||||||
|
|
||||||
|
await sql.query("DELETE FROM projects WHERE project_id=?", [id]);
|
||||||
res.json({ ok: 1 });
|
res.json({ ok: 1 });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
126
backend/src/routes/rbac_admin copy.js
Normal file
126
backend/src/routes/rbac_admin copy.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// FILE: backend/src/routes/rbac_admin.js
|
||||||
|
// RBAC admin — ใช้ settings.manage ทั้งหมด
|
||||||
|
import { Router } from "express";
|
||||||
|
import sql from "../db/index.js";
|
||||||
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
|
|
||||||
|
const r = Router();
|
||||||
|
|
||||||
|
// ROLES
|
||||||
|
r.get("/roles", requirePerm("settings.manage"), async (_req, res) => {
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
"SELECT role_id, role_code, role_name, description FROM roles ORDER BY role_code"
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PERMISSIONS
|
||||||
|
r.get("/permissions", requirePerm("settings.manage"), async (_req, res) => {
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
"SELECT permission_id, perm_code AS permission_code, scope_level, description FROM permissions ORDER BY perm_code"
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
// role -> permissions
|
||||||
|
r.get(
|
||||||
|
"/roles/:role_id/permissions",
|
||||||
|
requirePerm("settings.manage"),
|
||||||
|
async (req, res) => {
|
||||||
|
const role_id = Number(req.params.role_id);
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
`SELECT p.permission_id, p.perm_code AS permission_code, p.description
|
||||||
|
FROM role_permissions rp
|
||||||
|
JOIN permissions p ON p.permission_id = rp.permission_id
|
||||||
|
WHERE rp.role_id=? ORDER BY p.perm_code`,
|
||||||
|
[role_id]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
r.post(
|
||||||
|
"/roles/:role_id/permissions",
|
||||||
|
requirePerm("settings.manage"),
|
||||||
|
async (req, res) => {
|
||||||
|
const role_id = Number(req.params.role_id);
|
||||||
|
const { permission_id } = req.body || {};
|
||||||
|
await sql.query(
|
||||||
|
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?,?)",
|
||||||
|
[role_id, Number(permission_id)]
|
||||||
|
);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
r.delete(
|
||||||
|
"/roles/:role_id/permissions/:permission_id",
|
||||||
|
requirePerm("settings.manage"),
|
||||||
|
async (req, res) => {
|
||||||
|
const role_id = Number(req.params.role_id);
|
||||||
|
const permission_id = Number(req.params.permission_id);
|
||||||
|
await sql.query(
|
||||||
|
"DELETE FROM role_permissions WHERE role_id=? AND permission_id=?",
|
||||||
|
[role_id, permission_id]
|
||||||
|
);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// user -> roles (global/org/project scope columns มีในตาราง user_roles ตามสคีมา)
|
||||||
|
r.get(
|
||||||
|
"/users/:user_id/roles",
|
||||||
|
requirePerm("settings.manage"),
|
||||||
|
async (req, res) => {
|
||||||
|
const user_id = Number(req.params.user_id);
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
`SELECT ur.user_id, ur.role_id, r.role_code, r.role_name, ur.org_id, ur.project_id
|
||||||
|
FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id
|
||||||
|
WHERE ur.user_id=? ORDER BY r.role_code`,
|
||||||
|
[user_id]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
r.post(
|
||||||
|
"/users/:user_id/roles",
|
||||||
|
requirePerm("settings.manage"),
|
||||||
|
async (req, res) => {
|
||||||
|
const user_id = Number(req.params.user_id);
|
||||||
|
const { role_id, org_id = null, project_id = null } = req.body || {};
|
||||||
|
await sql.query(
|
||||||
|
"INSERT INTO user_roles (user_id, role_id, org_id, project_id) VALUES (?,?,?,?)",
|
||||||
|
[
|
||||||
|
user_id,
|
||||||
|
Number(role_id),
|
||||||
|
org_id ? Number(org_id) : null,
|
||||||
|
project_id ? Number(project_id) : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
r.delete(
|
||||||
|
"/users/:user_id/roles",
|
||||||
|
requirePerm("settings.manage"),
|
||||||
|
async (req, res) => {
|
||||||
|
const user_id = Number(req.params.user_id);
|
||||||
|
const { role_id, org_id = null, project_id = null } = req.body || {};
|
||||||
|
// สร้างเงื่อนไขแบบ dynamic สำหรับ NULL-safe compare
|
||||||
|
const whereOrg = org_id === null ? "ur.org_id IS NULL" : "ur.org_id = ?";
|
||||||
|
const wherePrj =
|
||||||
|
project_id === null ? "ur.project_id IS NULL" : "ur.project_id = ?";
|
||||||
|
const params = [user_id, Number(role_id)];
|
||||||
|
if (org_id !== null) params.push(Number(org_id));
|
||||||
|
if (project_id !== null) params.push(Number(project_id));
|
||||||
|
await sql.query(
|
||||||
|
`DELETE FROM user_roles ur WHERE ur.user_id=? AND ur.role_id=? AND ${whereOrg} AND ${wherePrj}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default r;
|
||||||
177
backend/src/routes/rbac_admin.js
Normal file → Executable file
177
backend/src/routes/rbac_admin.js
Normal file → Executable file
@@ -1,105 +1,88 @@
|
|||||||
// src/routes/rbac_admin.js (ESM)
|
// FILE: backend/src/routes/rbac_admin.js
|
||||||
import { Router } from 'express';
|
import { Router } from "express";
|
||||||
import sql from '../db/index.js';
|
import { Role, Permission, UserProjectRole, Project } from "../db/sequelize.js";
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
import { authJwt } from "../middleware/authJwt.js";
|
||||||
import PERM from '../config/permissions.js';
|
import { loadPrincipalMw } from "../middleware/loadPrincipal.js"; // แก้ไข: import ให้ถูกต้อง
|
||||||
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
|
|
||||||
const r = Router();
|
const router = Router();
|
||||||
|
|
||||||
/** LIST: roles */
|
// Middleware Chain ที่ถูกต้อง 100% ตามสถาปัตยกรรมของคุณ
|
||||||
r.get('/roles',
|
router.use(authJwt(), loadPrincipalMw());
|
||||||
requirePerm(PERM.rbac_admin.read, { scope: 'global' }),
|
|
||||||
async (req, res) => {
|
// == ROLES Management ==
|
||||||
const [rows] = await sql.query('SELECT role_id, role_code, role_name, description FROM roles ORDER BY role_code');
|
router.get("/roles", requirePerm("roles.manage"), async (req, res, next) => {
|
||||||
res.json(rows);
|
try {
|
||||||
|
const roles = await Role.findAll({
|
||||||
|
include: [{ model: Permission, attributes: ["id", "name"], through: { attributes: [] } }],
|
||||||
|
order: [["name", "ASC"]],
|
||||||
|
});
|
||||||
|
res.json(roles);
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/roles", requirePerm("roles.manage"), async (req, res, next) => {
|
||||||
|
const { name, description } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ message: "Role name is required." });
|
||||||
|
try {
|
||||||
|
const newRole = await Role.create({ name, description });
|
||||||
|
res.status(201).json(newRole);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === "SequelizeUniqueConstraintError") {
|
||||||
|
return res.status(409).json({ message: `Role '${name}' already exists.` });
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
/** LIST: permissions */
|
router.put("/roles/:id/permissions", requirePerm("roles.manage"), async (req, res, next) => {
|
||||||
r.get('/permissions',
|
const { permissionIds } = req.body;
|
||||||
requirePerm(PERM.rbac_admin.read, { scope: 'global' }),
|
if (!Array.isArray(permissionIds)) return res.status(400).json({ message: "permissionIds must be an array." });
|
||||||
async (req, res) => {
|
try {
|
||||||
const [rows] = await sql.query('SELECT permission_id, permission_code, description FROM permissions ORDER BY permission_code');
|
const role = await Role.findByPk(req.params.id);
|
||||||
res.json(rows);
|
if (!role) return res.status(404).json({ message: "Role not found." });
|
||||||
}
|
await role.setPermissions(permissionIds);
|
||||||
);
|
const updatedRole = await Role.findByPk(req.params.id, {
|
||||||
|
include: [{ model: Permission, attributes: ['id', 'name'], through: { attributes: [] } }]
|
||||||
|
});
|
||||||
|
res.json(updatedRole);
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
});
|
||||||
|
|
||||||
/** LIST: role→permissions */
|
// == USER-PROJECT-ROLES Management ==
|
||||||
r.get('/roles/:role_id/permissions',
|
router.get("/user-project-roles", requirePerm("users.manage"), async (req, res, next) => {
|
||||||
requirePerm(PERM.rbac_admin.read, { scope: 'global' }),
|
const { userId } = req.query;
|
||||||
async (req, res) => {
|
if (!userId) return res.status(400).json({ message: "userId query parameter is required." });
|
||||||
const role_id = Number(req.params.role_id);
|
try {
|
||||||
const [rows] = await sql.query(
|
const assignments = await UserProjectRole.findAll({
|
||||||
`SELECT p.permission_id, p.permission_code, p.description
|
where: { user_id: userId },
|
||||||
FROM role_permissions rp
|
include: [ { model: Project, attributes: ["id", "name"] }, { model: Role, attributes: ["id", "name"] } ],
|
||||||
JOIN permissions p ON p.permission_id = rp.permission_id
|
});
|
||||||
WHERE rp.role_id=? ORDER BY p.permission_code`, [role_id]);
|
res.json(assignments);
|
||||||
res.json(rows);
|
} catch (error) { next(error); }
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
/** MAP: role↔permission (grant/revoke) */
|
router.post("/user-project-roles", requirePerm("users.manage"), async (req, res, next) => {
|
||||||
r.post('/roles/:role_id/permissions',
|
const { userId, projectId, roleId } = req.body;
|
||||||
requirePerm(PERM.rbac_admin.grant_perm, { scope: 'global' }),
|
if (!userId || !projectId || !roleId) return res.status(400).json({ message: "userId, projectId, and roleId are required." });
|
||||||
async (req, res) => {
|
try {
|
||||||
const role_id = Number(req.params.role_id);
|
const [assignment, created] = await UserProjectRole.findOrCreate({
|
||||||
const { permission_id } = req.body || {};
|
where: { user_id: userId, project_id: projectId, role_id: roleId },
|
||||||
await sql.query('INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?,?)',
|
defaults: { user_id: userId, project_id: projectId, role_id: roleId },
|
||||||
[role_id, Number(permission_id)]);
|
});
|
||||||
res.json({ ok: 1 });
|
if (!created) return res.status(409).json({ message: "This assignment already exists." });
|
||||||
}
|
res.status(201).json(assignment);
|
||||||
);
|
} catch (error) { next(error); }
|
||||||
|
});
|
||||||
|
|
||||||
r.delete('/roles/:role_id/permissions/:permission_id',
|
router.delete("/user-project-roles", requirePerm("users.manage"), async (req, res, next) => {
|
||||||
requirePerm(PERM.rbac_admin.grant_perm, { scope: 'global' }),
|
const { userId, projectId, roleId } = req.body;
|
||||||
async (req, res) => {
|
if (!userId || !projectId || !roleId) return res.status(400).json({ message: "userId, projectId, and roleId are required." });
|
||||||
const role_id = Number(req.params.role_id);
|
try {
|
||||||
const permission_id = Number(req.params.permission_id);
|
const deletedCount = await UserProjectRole.destroy({ where: { user_id: userId, project_id: projectId, role_id: roleId } });
|
||||||
await sql.query('DELETE FROM role_permissions WHERE role_id=? AND permission_id=?', [role_id, permission_id]);
|
if (deletedCount === 0) return res.status(404).json({ message: 'Assignment not found.' });
|
||||||
res.json({ ok: 1 });
|
res.status(204).send();
|
||||||
}
|
} catch (error) { next(error); }
|
||||||
);
|
});
|
||||||
|
|
||||||
/** LIST: user→roles(+scope) */
|
export default router;
|
||||||
r.get('/users/:user_id/roles',
|
|
||||||
requirePerm(PERM.rbac_admin.read, { scope: 'global' }),
|
|
||||||
async (req, res) => {
|
|
||||||
const user_id = Number(req.params.user_id);
|
|
||||||
const [rows] = await sql.query(
|
|
||||||
`SELECT ur.user_id, ur.role_id, r.role_code, r.role_name, ur.org_id, ur.project_id
|
|
||||||
FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id
|
|
||||||
WHERE ur.user_id=? ORDER BY r.role_code`, [user_id]);
|
|
||||||
res.json(rows);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/** MAP: user↔role(+scope) (assign / revoke) */
|
|
||||||
r.post('/users/:user_id/roles',
|
|
||||||
requirePerm(PERM.rbac_admin.assign_role, { scope: 'global' }),
|
|
||||||
async (req, res) => {
|
|
||||||
const user_id = Number(req.params.user_id);
|
|
||||||
const { role_id, org_id = null, project_id = null } = req.body || {};
|
|
||||||
await sql.query(
|
|
||||||
'INSERT INTO user_roles (user_id, role_id, org_id, project_id) VALUES (?,?,?,?)',
|
|
||||||
[user_id, Number(role_id), org_id ? Number(org_id) : null, project_id ? Number(project_id) : null]
|
|
||||||
);
|
|
||||||
res.json({ ok: 1 });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
r.delete('/users/:user_id/roles',
|
|
||||||
requirePerm(PERM.rbac_admin.assign_role, { scope: 'global' }),
|
|
||||||
async (req, res) => {
|
|
||||||
const user_id = Number(req.params.user_id);
|
|
||||||
const { role_id, org_id = null, project_id = null } = req.body || {};
|
|
||||||
await sql.query(
|
|
||||||
'DELETE FROM user_roles WHERE user_id=? AND role_id=? AND <=> org_id ? AND <=> project_id ?'
|
|
||||||
.replace('<=> org_id ?', (org_id === null ? 'org_id IS ?' : 'org_id=?'))
|
|
||||||
.replace('<=> project_id ?', (project_id === null ? 'project_id IS ?' : 'project_id=?')),
|
|
||||||
[user_id, Number(role_id), org_id, project_id]
|
|
||||||
);
|
|
||||||
res.json({ ok: 1 });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default r;
|
|
||||||
@@ -1,35 +1,91 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/rfa.js
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
// RFA: create + update-status ผ่าน stored procedures
|
||||||
import { requirePermission } from '../middleware/perm.js';
|
import { Router } from "express";
|
||||||
import { callProc } from '../db/index.js';
|
import sql, { callProc } from "../db/index.js";
|
||||||
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
|
|
||||||
const router = Router();
|
const r = Router();
|
||||||
|
|
||||||
router.post('/create',
|
// CREATE (PROJECT scope) -> rfas.create
|
||||||
requireAuth,
|
r.post(
|
||||||
requirePermission(['RFA_CREATE'], { projectRequired: true }),
|
"/create",
|
||||||
|
requirePerm("rfas.create", { projectParam: "project_id" }),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { project_id, cor_status_id, cor_no, title, originator_id, recipient_id, keywords = null, pdf_path = null, item_doc_ids = [] } = req.body || {};
|
const {
|
||||||
const json = JSON.stringify(item_doc_ids.map(Number));
|
project_id,
|
||||||
await callProc('sp_rfa_create_with_items', [
|
cor_status_id,
|
||||||
req.user.user_id, project_id, cor_status_id, cor_no, title, originator_id, recipient_id, keywords, pdf_path, json, null
|
cor_no,
|
||||||
|
title,
|
||||||
|
originator_id,
|
||||||
|
recipient_id,
|
||||||
|
keywords = null,
|
||||||
|
pdf_path = null,
|
||||||
|
item_doc_ids = [],
|
||||||
|
} = req.body || {};
|
||||||
|
|
||||||
|
if (!project_id || !title) {
|
||||||
|
return res.status(400).json({ error: "project_id and title required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = JSON.stringify((item_doc_ids || []).map(Number));
|
||||||
|
await callProc("sp_rfa_create_with_items", [
|
||||||
|
req.principal.user_id,
|
||||||
|
project_id,
|
||||||
|
cor_status_id ?? null,
|
||||||
|
cor_no ?? null,
|
||||||
|
title,
|
||||||
|
originator_id ?? null,
|
||||||
|
recipient_id ?? null,
|
||||||
|
keywords,
|
||||||
|
pdf_path,
|
||||||
|
json,
|
||||||
|
null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
res.status(201).json({ ok: true });
|
res.status(201).json({ ok: true });
|
||||||
} catch (e) { next(e); }
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
router.post('/update-status',
|
// UPDATE STATUS (PROJECT scope) -> rfas.respond
|
||||||
requireAuth,
|
r.post(
|
||||||
requirePermission(['RFA_STATUS_UPDATE'], { projectRequired: true }),
|
"/update-status",
|
||||||
|
requirePerm("rfas.respond"),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { rfa_corr_id, status_id, set_issue = 0 } = req.body || {};
|
const { rfa_corr_id, status_id, set_issue = 0 } = req.body || {};
|
||||||
await callProc('sp_rfa_update_status', [req.user.user_id, rfa_corr_id, status_id, set_issue ? 1 : 0]);
|
if (!rfa_corr_id || !status_id) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "rfa_corr_id and status_id required" });
|
||||||
|
}
|
||||||
|
// enforce ABAC: find project_id of the RFA
|
||||||
|
const [[ref]] = await sql.query(
|
||||||
|
"SELECT project_id FROM rfas WHERE id=? LIMIT 1",
|
||||||
|
[Number(rfa_corr_id)]
|
||||||
|
);
|
||||||
|
if (!ref) return res.status(404).json({ error: "RFA not found" });
|
||||||
|
if (
|
||||||
|
!req.principal.is_superadmin &&
|
||||||
|
!req.principal.inProject(ref.project_id)
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await callProc("sp_rfa_update_status", [
|
||||||
|
req.principal.user_id,
|
||||||
|
rfa_corr_id,
|
||||||
|
status_id,
|
||||||
|
set_issue ? 1 : 0,
|
||||||
|
]);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) { next(e); }
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export default router;
|
export default r;
|
||||||
|
|||||||
@@ -1,206 +1,167 @@
|
|||||||
// backend/src/routes/rfas.js (merged)
|
// FILE: backend/src/routes/rfas.js
|
||||||
// Base: rfas.js + enhanced list/sort/paging/overdue from rfas-1.js
|
// RFAs list/get/create/update/delete — มาตรฐาน Bearer + requirePerm
|
||||||
|
import { Router } from "express";
|
||||||
import { Router } from 'express';
|
import sql from "../db/index.js";
|
||||||
import sql from '../db/index.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
|
||||||
import { buildScopeWhere, ownerResolvers } from '../utils/scope.js';
|
|
||||||
// import PERM from '../config/permissions.js'; // ถ้าไม่ได้ใช้ สามารถลบได้
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const OWN = ownerResolvers(sql, 'rfas', 'id');
|
|
||||||
|
|
||||||
/* ----------------------------- Utilities ----------------------------- */
|
|
||||||
// Allow-list สำหรับการ sort ป้องกัน SQL injection
|
|
||||||
const ALLOWED_SORT = new Map([
|
const ALLOWED_SORT = new Map([
|
||||||
['updated_at', 'updated_at'],
|
["updated_at", "updated_at"],
|
||||||
['due_date', 'due_date'],
|
["due_date", "due_date"],
|
||||||
['created_at', 'created_at'],
|
["created_at", "created_at"],
|
||||||
['id', 'id']
|
["id", "id"],
|
||||||
]);
|
]);
|
||||||
|
function parseSort(sort = "updated_at:desc") {
|
||||||
function parseSort(sort = 'updated_at:desc') {
|
const [c, d] = String(sort).split(":");
|
||||||
const [colRaw, dirRaw] = String(sort).split(':');
|
const col = ALLOWED_SORT.get(c) || "updated_at";
|
||||||
const col = ALLOWED_SORT.get(colRaw) || 'updated_at';
|
const dir = (d || "desc").toLowerCase() === "asc" ? "ASC" : "DESC";
|
||||||
const dir = (dirRaw || 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
|
||||||
return `\`${col}\` ${dir}`;
|
return `\`${col}\` ${dir}`;
|
||||||
}
|
}
|
||||||
|
function paging({ page = 1, pageSize = 20 }) {
|
||||||
function parsePaging({ page = 1, pageSize = 20 }) {
|
|
||||||
const p = Math.max(1, Number(page) || 1);
|
const p = Math.max(1, Number(page) || 1);
|
||||||
const ps = Math.min(200, Math.max(1, Number(pageSize) || 20));
|
const ps = Math.min(200, Math.max(1, Number(pageSize) || 20));
|
||||||
return { limit: ps, offset: (p - 1) * ps, page: p, pageSize: ps };
|
return { limit: ps, offset: (p - 1) * ps, page: p, pageSize: ps };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ตัวกรองเพิ่มเติม (จาก rfas-1.js) + ผสมกับเงื่อนไข scope เดิม
|
// LIST (PROJECT scope enforced: filter ด้วย principal)
|
||||||
function buildExtraFilters({ q, status, overdue, project_id, org_id }) {
|
r.get(
|
||||||
const parts = [];
|
"/",
|
||||||
const params = {};
|
requirePerm("rfas.view", { projectParam: "project_id" }),
|
||||||
if (project_id) { parts.push('r.project_id = :project_id'); params.project_id = Number(project_id); }
|
|
||||||
if (org_id) { parts.push('r.org_id = :org_id'); params.org_id = Number(org_id); }
|
|
||||||
if (status) { parts.push('r.status = :status'); params.status = status; }
|
|
||||||
if (q) { parts.push('(r.rfa_no LIKE :q OR r.title LIKE :q OR r.code LIKE :q)'); params.q = `%${q}%`; }
|
|
||||||
if (String(overdue) === '1') {
|
|
||||||
// overdue: due_date < TODAY และสถานะยังไม่ปิด
|
|
||||||
parts.push("r.due_date IS NOT NULL AND r.due_date < CURDATE() AND r.status NOT IN ('Closed','Approved')");
|
|
||||||
}
|
|
||||||
return { where: parts.join(' AND '), params };
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------- LIST --------------------------------
|
|
||||||
GET /rfas
|
|
||||||
- คง requirePerm แบบ rfas.js (scope:global + project/org scope ผ่าน buildScopeWhere)
|
|
||||||
- เพิ่ม faceted filters/sort/paging/overdue จาก rfas-1.js
|
|
||||||
------------------------------------------------------------------------*/
|
|
||||||
r.get('/',
|
|
||||||
requirePerm('rfa.read', { scope: 'global' }),
|
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
const { q, status, overdue, sort, page, pageSize, project_id } = req.query;
|
||||||
const { q, status, overdue, sort, page, pageSize, project_id, org_id } = req.query;
|
const orderBy = parseSort(sort);
|
||||||
const orderBy = parseSort(sort);
|
const { limit, offset, page: p, pageSize: ps } = paging({ page, pageSize });
|
||||||
const { limit, offset, page: p, pageSize: ps } = parsePaging({ page, pageSize });
|
|
||||||
|
|
||||||
// base scope จาก principal (org/project)
|
const P = req.principal;
|
||||||
const base = buildScopeWhere(req.principal, {
|
const cond = [];
|
||||||
tableAlias: 'r', orgColumn: 'r.org_id', projectColumn: 'r.project_id',
|
const params = [];
|
||||||
permCode: 'rfa.read', preferProject: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// extra filters
|
if (!P.is_superadmin) {
|
||||||
const extra = buildExtraFilters({ q, status, overdue, project_id, org_id });
|
if (project_id) {
|
||||||
|
const pid = Number(project_id);
|
||||||
// รวม where
|
if (!P.inProject(pid))
|
||||||
const where = [base.where, extra.where].filter(Boolean).join(' AND ') || '1=1';
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
const params = { ...base.params, ...extra.params, limit, offset };
|
cond.push("r.project_id=?");
|
||||||
|
params.push(pid);
|
||||||
// total
|
} else if (P.project_ids?.length) {
|
||||||
const [[{ cnt: total }]] = await sql.query(
|
cond.push(
|
||||||
`SELECT COUNT(*) AS cnt FROM rfas r WHERE ${where}`,
|
`r.project_id IN (${P.project_ids.map(() => "?").join(",")})`
|
||||||
params
|
);
|
||||||
);
|
params.push(...P.project_ids);
|
||||||
|
|
||||||
// rows
|
|
||||||
const [rows] = await sql.query(
|
|
||||||
`SELECT r.id, r.code, r.rfa_no, r.title, r.status, r.discipline, r.due_date, r.owner_id, r.updated_at, r.project_id, r.org_id
|
|
||||||
FROM rfas r
|
|
||||||
WHERE ${where}
|
|
||||||
ORDER BY ${orderBy}
|
|
||||||
LIMIT :limit OFFSET :offset`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({ data: rows, total: Number(total || 0), page: p, pageSize: ps });
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: e.message || 'rfas/list failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ------------------------------- GET ONE ------------------------------
|
|
||||||
// ยึดรูปแบบตรวจสิทธิ์จาก rfas.js
|
|
||||||
------------------------------------------------------------------------*/
|
|
||||||
r.get('/:id',
|
|
||||||
requirePerm('rfa.read', { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
const [[row]] = await sql.query('SELECT * FROM rfas WHERE id=?', [id]);
|
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
|
||||||
res.json(row);
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: e.message || 'rfas/detail failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ------------------------------- CREATE -------------------------------
|
|
||||||
// ยึดรูปแบบฟิลด์ของ rfas.js (org_id, project_id, rfa_no, title, status)
|
|
||||||
// เพิ่ม validation เบื้องต้น (title required)
|
|
||||||
------------------------------------------------------------------------*/
|
|
||||||
r.post('/',
|
|
||||||
requirePerm('rfa.create', { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { org_id, project_id, rfa_no, title, status } = req.body || {};
|
|
||||||
if (!title?.trim()) return res.status(400).json({ error: 'title is required' });
|
|
||||||
|
|
||||||
const st = String(status || '').trim() || 'draft';
|
|
||||||
const [rs] = await sql.query(
|
|
||||||
`INSERT INTO rfas (org_id, project_id, rfa_no, title, status, created_by, created_at, updated_at)
|
|
||||||
VALUES (?,?,?,?,?,?,NOW(),NOW())`,
|
|
||||||
[org_id, project_id, rfa_no, title, st, req.principal.userId]
|
|
||||||
);
|
|
||||||
res.status(201).json({ id: rs.insertId });
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: e.message || 'rfas/create failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ------------------------------- UPDATE -------------------------------
|
|
||||||
// PUT: รูปแบบเดิมของ rfas.js (อัปเดต title, status)
|
|
||||||
// PATCH: แบบยืดหยุ่น (บางฟิลด์) ผสานแนวทางจาก rfas-1.js
|
|
||||||
------------------------------------------------------------------------*/
|
|
||||||
r.put('/:id',
|
|
||||||
requirePerm('rfa.update', { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
const { title, status } = req.body || {};
|
|
||||||
await sql.query('UPDATE rfas SET title=?, status=?, updated_at=NOW() WHERE id=?', [title, status, id]);
|
|
||||||
res.json({ ok: 1, id });
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: e.message || 'rfas/update failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// PATCH แบบ partial fields
|
|
||||||
r.patch('/:id',
|
|
||||||
requirePerm('rfa.update', { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
const allowed = ['code', 'rfa_no', 'title', 'discipline', 'due_date', 'description', 'status', 'owner_id'];
|
|
||||||
const patch = {};
|
|
||||||
for (const k of allowed) if (k in req.body) patch[k] = req.body[k];
|
|
||||||
|
|
||||||
if (Object.keys(patch).length === 0) {
|
|
||||||
return res.status(400).json({ error: 'no fields to update' });
|
|
||||||
}
|
}
|
||||||
|
} else if (project_id) {
|
||||||
|
cond.push("r.project_id=?");
|
||||||
|
params.push(Number(project_id));
|
||||||
|
}
|
||||||
|
|
||||||
if ('status' in patch) {
|
if (status) {
|
||||||
const s = String(patch.status);
|
cond.push("r.status=?");
|
||||||
const ok = ['draft','submitted','Pending','Review','Approved','Closed'].includes(s);
|
params.push(status);
|
||||||
if (!ok) return res.status(400).json({ error: 'invalid status' });
|
}
|
||||||
}
|
if (q) {
|
||||||
|
cond.push("(r.rfa_no LIKE ? OR r.title LIKE ? OR r.code LIKE ?)");
|
||||||
const sets = Object.keys(patch).map(k => `\`${k}\` = :${k}`);
|
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
|
||||||
patch.id = id;
|
}
|
||||||
|
if (String(overdue) === "1") {
|
||||||
await sql.query(
|
cond.push(
|
||||||
`UPDATE rfas SET ${sets.join(', ')}, updated_at=NOW() WHERE id=:id`,
|
"r.due_date IS NOT NULL AND r.due_date < CURDATE() AND r.status NOT IN ('Closed','Approved')"
|
||||||
patch
|
|
||||||
);
|
);
|
||||||
res.json({ ok: 1, id });
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: e.message || 'rfas/patch failed' });
|
|
||||||
}
|
}
|
||||||
|
const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
|
||||||
|
|
||||||
|
const [[{ cnt }]] = await sql.query(
|
||||||
|
`SELECT COUNT(*) AS cnt FROM rfas r ${where}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
`SELECT r.id, r.code, r.rfa_no, r.title, r.status, r.discipline, r.due_date, r.updated_at, r.project_id
|
||||||
|
FROM rfas r ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
|
||||||
|
[...params, limit, offset]
|
||||||
|
);
|
||||||
|
res.json({ data: rows, total: Number(cnt || 0), page: p, pageSize: ps });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ------------------------------- DELETE ------------------------------- */
|
// GET ONE
|
||||||
r.delete('/:id',
|
r.get("/:id", requirePerm("rfas.view"), async (req, res) => {
|
||||||
requirePerm('rfa.delete', { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
const id = Number(req.params.id);
|
||||||
|
const [[row]] = await sql.query("SELECT * FROM rfas WHERE id=?", [id]);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const P = req.principal;
|
||||||
|
if (!P.is_superadmin && !P.inProject(row.project_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
res.json(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// CREATE
|
||||||
|
r.post(
|
||||||
|
"/",
|
||||||
|
requirePerm("rfas.create", { projectParam: "project_id" }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
const { org_id, project_id, rfa_no, title, status } = req.body || {};
|
||||||
const id = Number(req.params.id);
|
if (!project_id || !title)
|
||||||
await sql.query('DELETE FROM rfas WHERE id=?', [id]);
|
return res.status(400).json({ error: "project_id and title required" });
|
||||||
res.json({ ok: 1, id });
|
const [rs] = await sql.query(
|
||||||
} catch (e) {
|
`INSERT INTO rfas (org_id, project_id, rfa_no, title, status, created_by, created_at, updated_at)
|
||||||
res.status(500).json({ error: e.message || 'rfas/delete failed' });
|
VALUES (?,?,?,?,?,?,NOW(),NOW())`,
|
||||||
}
|
[
|
||||||
|
org_id ?? null,
|
||||||
|
project_id,
|
||||||
|
rfa_no ?? null,
|
||||||
|
title,
|
||||||
|
status ?? "draft",
|
||||||
|
req.principal.user_id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
res.status(201).json({ id: rs.insertId });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// UPDATE (respond/edit)
|
||||||
|
r.patch("/:id", requirePerm("rfas.respond"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const [[row]] = await sql.query("SELECT * FROM rfas WHERE id=?", [id]);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const P = req.principal;
|
||||||
|
if (!P.is_superadmin && !P.inProject(row.project_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
|
||||||
|
const allowed = [
|
||||||
|
"code",
|
||||||
|
"rfa_no",
|
||||||
|
"title",
|
||||||
|
"discipline",
|
||||||
|
"due_date",
|
||||||
|
"description",
|
||||||
|
"status",
|
||||||
|
"owner_id",
|
||||||
|
];
|
||||||
|
const patch = {};
|
||||||
|
for (const k of allowed) if (k in req.body) patch[k] = req.body[k];
|
||||||
|
if (!Object.keys(patch).length)
|
||||||
|
return res.status(400).json({ error: "no fields to update" });
|
||||||
|
|
||||||
|
const sets = Object.keys(patch).map((k) => `\`${k}\`=?`);
|
||||||
|
await sql.query(
|
||||||
|
`UPDATE rfas SET ${sets.join(", ")}, updated_at=NOW() WHERE id=?`,
|
||||||
|
[...Object.values(patch), id]
|
||||||
|
);
|
||||||
|
res.json({ ok: 1, id });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
r.delete("/:id", requirePerm("rfas.delete"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const [[row]] = await sql.query("SELECT project_id FROM rfas WHERE id=?", [
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const P = req.principal;
|
||||||
|
if (!P.is_superadmin && !P.inProject(row.project_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
await sql.query("DELETE FROM rfas WHERE id=?", [id]);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
|
|||||||
@@ -1,48 +1,95 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/subcategories.js
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
// Master data: subcategories — GLOBAL read/write (ตาม categories.js)
|
||||||
import { enrichPermissions } from '../middleware/permissions.js';
|
import { Router } from "express";
|
||||||
import { requireRole } from '../middleware/rbac.js';
|
import sql from "../db/index.js";
|
||||||
import { requirePerm } from '../middleware/permGuard.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
import { sequelize } from '../db/sequelize.js';
|
|
||||||
import SubCatModel from '../db/models/SubCategory.js';
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const SubCat = SubCatModel(sequelize);
|
|
||||||
|
|
||||||
r.get('/sub_categories', requireAuth, async (req, res) => {
|
// LIST (GLOBAL read)
|
||||||
const { q, project_id, page=1, page_size=50 } = req.query;
|
r.get(
|
||||||
const limit = Math.min(Number(page_size)||50, 200);
|
"/sub_categories",
|
||||||
const offset = (Math.max(Number(page)||1,1)-1) * limit;
|
requirePerm("organizations.view"),
|
||||||
const where = {};
|
async (req, res) => {
|
||||||
if (project_id) where.project_id = project_id;
|
const { q, cat_id, page = 1, page_size = 50 } = req.query;
|
||||||
if (q) where.sub_cat_name = sequelize.where(sequelize.fn('LOWER', sequelize.col('sub_cat_name')), 'LIKE', `%${String(q).toLowerCase()}%`);
|
const limit = Math.min(Number(page_size) || 50, 200);
|
||||||
const { rows, count } = await SubCat.findAndCountAll({ where, limit, offset, order:[['sub_cat_name','ASC']] });
|
const offset = (Math.max(Number(page) || 1, 1) - 1) * limit;
|
||||||
res.json({ items: rows, total: count, page: Number(page), page_size: limit });
|
|
||||||
|
const cond = [];
|
||||||
|
const params = [];
|
||||||
|
if (cat_id) {
|
||||||
|
cond.push("cat_id=?");
|
||||||
|
params.push(Number(cat_id));
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
cond.push("LOWER(sub_cat_name) LIKE ?");
|
||||||
|
params.push(`%${String(q).toLowerCase()}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
|
||||||
|
const [[{ cnt }]] = await sql.query(
|
||||||
|
`SELECT COUNT(*) AS cnt FROM subcategories ${where}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
`SELECT * FROM subcategories ${where} ORDER BY sub_cat_name ASC LIMIT ? OFFSET ?`,
|
||||||
|
[...params, limit, offset]
|
||||||
|
);
|
||||||
|
res.json({
|
||||||
|
items: rows,
|
||||||
|
total: Number(cnt || 0),
|
||||||
|
page: Number(page) || 1,
|
||||||
|
page_size: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// CREATE (GLOBAL write)
|
||||||
|
r.post("/sub_categories", requirePerm("settings.manage"), async (req, res) => {
|
||||||
|
const { cat_id, sub_cat_name, code } = req.body || {};
|
||||||
|
if (!cat_id || !sub_cat_name)
|
||||||
|
return res.status(400).json({ error: "cat_id and sub_cat_name required" });
|
||||||
|
const [rs] = await sql.query(
|
||||||
|
"INSERT INTO subcategories (cat_id, sub_cat_name, code) VALUES (?,?,?)",
|
||||||
|
[Number(cat_id), sub_cat_name, code ?? null]
|
||||||
|
);
|
||||||
|
res.status(201).json({ sub_cat_id: rs.insertId });
|
||||||
});
|
});
|
||||||
|
|
||||||
r.post('/sub_categories', requireAuth, enrichPermissions(), requirePerm('lookup:edit'), async (req, res) => {
|
// UPDATE
|
||||||
const { project_id, sub_cat_name, parent_cat_id, code } = req.body || {};
|
r.patch(
|
||||||
if (!project_id || !sub_cat_name) return res.status(400).json({ error: 'project_id and sub_cat_name required' });
|
"/sub_categories/:id",
|
||||||
const created = await SubCat.create({ project_id, sub_cat_name, parent_cat_id, code });
|
requirePerm("settings.manage"),
|
||||||
res.status(201).json({ sub_cat_id: created.sub_cat_id });
|
async (req, res) => {
|
||||||
});
|
const id = Number(req.params.id);
|
||||||
|
const [[row]] = await sql.query(
|
||||||
|
"SELECT * FROM subcategories WHERE sub_cat_id=?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const { sub_cat_name, cat_id, code } = req.body || {};
|
||||||
|
await sql.query(
|
||||||
|
"UPDATE subcategories SET sub_cat_name=?, cat_id=?, code=? WHERE sub_cat_id=?",
|
||||||
|
[
|
||||||
|
sub_cat_name ?? row.sub_cat_name,
|
||||||
|
cat_id ?? row.cat_id,
|
||||||
|
code ?? row.code,
|
||||||
|
id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
r.patch('/sub_categories/:id', requireAuth, enrichPermissions(), requirePerm('lookup:edit'), async (req, res) => {
|
// DELETE
|
||||||
const row = await SubCat.findByPk(Number(req.params.id));
|
r.delete(
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
"/sub_categories/:id",
|
||||||
const { sub_cat_name, parent_cat_id, code } = req.body || {};
|
requirePerm("settings.manage"),
|
||||||
if (sub_cat_name !== undefined) row.sub_cat_name = sub_cat_name;
|
async (req, res) => {
|
||||||
if (parent_cat_id !== undefined) row.parent_cat_id = parent_cat_id;
|
const id = Number(req.params.id);
|
||||||
if (code !== undefined) row.code = code;
|
await sql.query("DELETE FROM subcategories WHERE sub_cat_id=?", [id]);
|
||||||
await row.save();
|
res.json({ ok: true });
|
||||||
res.json({ ok: true });
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
r.delete('/sub_categories/:id', requireAuth, enrichPermissions(), requirePerm('lookup:edit'), async (req, res) => {
|
|
||||||
const row = await SubCat.findByPk(Number(req.params.id));
|
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
|
||||||
await row.destroy();
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
|
|||||||
@@ -1,190 +1,124 @@
|
|||||||
// src/routes/technicaldocs.js (ESM)
|
// FILE: backend/src/routes/technicaldocs.js
|
||||||
import { Router } from 'express';
|
// แมปเป็นเอกสารประเภทหนึ่ง → ใช้สิทธิ์ documents.view/manage (PROJECT)
|
||||||
import sql from '../db/index.js';
|
import { Router } from "express";
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
import sql from "../db/index.js";
|
||||||
import { buildScopeWhere, ownerResolvers } from '../utils/scope.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
import PERM from '../config/permissions.js';
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const OWN = ownerResolvers(sql, 'technicaldocs', 'id');
|
|
||||||
|
|
||||||
// LIST (filter + scope)
|
|
||||||
r.get('/',
|
|
||||||
requirePerm(PERM.technicaldoc.read, { scope: 'global' }),
|
|
||||||
async (req, res) => {
|
|
||||||
const { project_id, org_id, status, q, limit = 50, offset = 0 } = req.query;
|
|
||||||
|
|
||||||
const base = buildScopeWhere(req.principal, {
|
|
||||||
tableAlias: 't',
|
|
||||||
orgColumn: 't.org_id',import { Router } from 'express';
|
|
||||||
import sql from '../db/index.js';
|
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
|
||||||
import { buildScopeWhere, ownerResolvers } from '../utils/scope.js';
|
|
||||||
import PERM from '../config/permissions.js';
|
|
||||||
|
|
||||||
const r = Router();
|
|
||||||
const OWN = ownerResolvers(sql, 'transmittals', 'id');
|
|
||||||
|
|
||||||
// LIST
|
// LIST
|
||||||
r.get('/',
|
r.get(
|
||||||
requirePerm(PERM.transmittal.read, { scope: 'global' }),
|
"/",
|
||||||
|
requirePerm("documents.view", { projectParam: "project_id" }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const { project_id, org_id, tr_no, q, limit = 50, offset = 0 } = req.query;
|
const { project_id, status, q, limit = 50, offset = 0 } = req.query;
|
||||||
|
const P = req.principal;
|
||||||
const base = buildScopeWhere(req.principal, {
|
const cond = [];
|
||||||
tableAlias: 't',
|
|
||||||
orgColumn: 't.org_id',
|
|
||||||
projectColumn: 't.project_id',
|
|
||||||
permCode: PERM.transmittal.read,
|
|
||||||
preferProject: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const extra = [];
|
|
||||||
const params = { ...base.params, limit: Number(limit), offset: Number(offset) };
|
|
||||||
if (project_id) { extra.push('t.project_id = :project_id'); params.project_id = Number(project_id); }
|
|
||||||
if (org_id) { extra.push('t.org_id = :org_id'); params.org_id = Number(org_id); }
|
|
||||||
if (tr_no) { extra.push('t.tr_no = :tr_no'); params.tr_no = tr_no; }
|
|
||||||
if (q) { extra.push('(t.tr_no LIKE :q OR t.subject LIKE :q)'); params.q = `%${q}%`; }
|
|
||||||
|
|
||||||
const where = [base.where, ...extra].filter(Boolean).join(' AND ');
|
|
||||||
|
|
||||||
const [rows] = await sql.query(
|
|
||||||
`SELECT t.* FROM transmittals t
|
|
||||||
WHERE ${where}
|
|
||||||
ORDER BY t.id DESC
|
|
||||||
LIMIT :limit OFFSET :offset`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
res.json(rows);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET
|
|
||||||
r.get('/:id',
|
|
||||||
requirePerm(PERM.transmittal.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
|
||||||
async (req, res) => {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
const [[row]] = await sql.query('SELECT * FROM transmittals WHERE id=?', [id]);
|
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
|
||||||
res.json(row);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// CREATE
|
|
||||||
r.post('/',
|
|
||||||
requirePerm(PERM.transmittal.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
|
|
||||||
async (req, res) => {
|
|
||||||
const { org_id, project_id, tr_no, subject, status } = req.body;
|
|
||||||
const [rs] = await sql.query(
|
|
||||||
`INSERT INTO transmittals (org_id, project_id, tr_no, subject, status, created_by)
|
|
||||||
VALUES (?,?,?,?,?,?)`,
|
|
||||||
[org_id, project_id, tr_no, subject, status, req.principal.userId]
|
|
||||||
);
|
|
||||||
res.json({ id: rs.insertId });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// UPDATE (รองรับ PATCH)
|
|
||||||
r.patch('/:id',
|
|
||||||
requirePerm(PERM.transmittal.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
|
||||||
async (req, res) => {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
const fields = [];
|
|
||||||
const params = [];
|
const params = [];
|
||||||
|
if (!P.is_superadmin) {
|
||||||
// อนุญาตแก้ฟิลด์หลัก
|
if (project_id) {
|
||||||
const allow = ['tr_no','subject','status'];
|
const pid = Number(project_id);
|
||||||
for (const k of allow) {
|
if (!P.inProject(pid))
|
||||||
if (k in req.body) {
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
fields.push(`${k} = ?`);
|
cond.push("t.project_id=?");
|
||||||
params.push(req.body[k]);
|
params.push(pid);
|
||||||
|
} else if (P.project_ids?.length) {
|
||||||
|
cond.push(
|
||||||
|
`t.project_id IN (${P.project_ids.map(() => "?").join(",")})`
|
||||||
|
);
|
||||||
|
params.push(...P.project_ids);
|
||||||
}
|
}
|
||||||
|
} else if (project_id) {
|
||||||
|
cond.push("t.project_id=?");
|
||||||
|
params.push(Number(project_id));
|
||||||
}
|
}
|
||||||
if (!fields.length) return res.status(400).json({ error: 'NO_FIELDS' });
|
|
||||||
|
|
||||||
params.push(id);
|
if (status) {
|
||||||
await sql.query(`UPDATE transmittals SET ${fields.join(', ')} WHERE id=?`, params);
|
cond.push("t.status=?");
|
||||||
res.json({ ok: 1 });
|
params.push(status);
|
||||||
}
|
}
|
||||||
);
|
if (q) {
|
||||||
|
cond.push("(t.doc_no LIKE ? OR t.title LIKE ?)");
|
||||||
|
params.push(`%${q}%`, `%${q}%`);
|
||||||
|
}
|
||||||
|
|
||||||
// DELETE
|
const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
|
||||||
r.delete('/:id',
|
|
||||||
requirePerm(PERM.transmittal.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
|
||||||
async (req, res) => {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
await sql.query('DELETE FROM transmittals WHERE id=?', [id]);
|
|
||||||
res.json({ ok: 1 });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default r;
|
|
||||||
|
|
||||||
projectColumn: 't.project_id',
|
|
||||||
permCode: PERM.technicaldoc.read,
|
|
||||||
preferProject: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const extra = [];
|
|
||||||
const params = { ...base.params, limit: Number(limit), offset: Number(offset) };
|
|
||||||
if (project_id) { extra.push('t.project_id = :project_id'); params.project_id = Number(project_id); }
|
|
||||||
if (org_id) { extra.push('t.org_id = :org_id'); params.org_id = Number(org_id); }
|
|
||||||
if (status) { extra.push('t.status = :status'); params.status = status; }
|
|
||||||
if (q) { extra.push('(t.doc_no LIKE :q OR t.title LIKE :q)'); params.q = `%${q}%`; }
|
|
||||||
|
|
||||||
const where = [base.where, ...extra].filter(Boolean).join(' AND ');
|
|
||||||
const [rows] = await sql.query(
|
const [rows] = await sql.query(
|
||||||
`SELECT t.* FROM technicaldocs t WHERE ${where}
|
`SELECT t.* FROM technicaldocs t ${where} ORDER BY t.id DESC LIMIT ? OFFSET ?`,
|
||||||
ORDER BY t.id DESC LIMIT :limit OFFSET :offset`, params
|
[...params, Number(limit), Number(offset)]
|
||||||
);
|
);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// GET
|
// GET
|
||||||
r.get('/:id',
|
r.get("/:id", requirePerm("documents.view"), async (req, res) => {
|
||||||
requirePerm(PERM.technicaldoc.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
const id = Number(req.params.id);
|
||||||
async (req, res) => {
|
const [[row]] = await sql.query("SELECT * FROM technicaldocs WHERE id=?", [
|
||||||
const id = Number(req.params.id);
|
id,
|
||||||
const [[row]] = await sql.query('SELECT * FROM technicaldocs WHERE id=?', [id]);
|
]);
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
res.json(row);
|
const P = req.principal;
|
||||||
}
|
if (!P.is_superadmin && !P.inProject(row.project_id))
|
||||||
);
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
res.json(row);
|
||||||
|
});
|
||||||
|
|
||||||
// CREATE
|
// CREATE
|
||||||
r.post('/',
|
r.post(
|
||||||
requirePerm(PERM.technicaldoc.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
|
"/",
|
||||||
|
requirePerm("documents.manage", { projectParam: "project_id" }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const { org_id, project_id, doc_no, title, status } = req.body;
|
const { org_id, project_id, doc_no, title, status } = req.body || {};
|
||||||
|
if (!project_id || !doc_no)
|
||||||
|
return res.status(400).json({ error: "project_id and doc_no required" });
|
||||||
const [rs] = await sql.query(
|
const [rs] = await sql.query(
|
||||||
`INSERT INTO technicaldocs (org_id, project_id, doc_no, title, status, created_by)
|
`INSERT INTO technicaldocs (org_id, project_id, doc_no, title, status, created_by)
|
||||||
VALUES (?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?)`,
|
||||||
[org_id, project_id, doc_no, title, status, req.principal.userId]
|
[
|
||||||
|
org_id ?? null,
|
||||||
|
project_id,
|
||||||
|
doc_no,
|
||||||
|
title ?? null,
|
||||||
|
status ?? null,
|
||||||
|
req.principal.user_id,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
res.json({ id: rs.insertId });
|
res.status(201).json({ id: rs.insertId });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// UPDATE
|
// UPDATE
|
||||||
r.put('/:id',
|
r.put("/:id", requirePerm("documents.manage"), async (req, res) => {
|
||||||
requirePerm(PERM.technicaldoc.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
const id = Number(req.params.id);
|
||||||
async (req, res) => {
|
const [[row]] = await sql.query("SELECT * FROM technicaldocs WHERE id=?", [
|
||||||
const id = Number(req.params.id);
|
id,
|
||||||
const { title, status } = req.body;
|
]);
|
||||||
await sql.query('UPDATE technicaldocs SET title=?, status=? WHERE id=?', [title, status, id]);
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
res.json({ ok: 1 });
|
const P = req.principal;
|
||||||
}
|
if (!P.is_superadmin && !P.inProject(row.project_id))
|
||||||
);
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
const { title, status } = req.body || {};
|
||||||
|
await sql.query("UPDATE technicaldocs SET title=?, status=? WHERE id=?", [
|
||||||
|
title ?? row.title,
|
||||||
|
status ?? row.status,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE
|
// DELETE
|
||||||
r.delete('/:id',
|
r.delete("/:id", requirePerm("documents.manage"), async (req, res) => {
|
||||||
requirePerm(PERM.technicaldoc.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
const id = Number(req.params.id);
|
||||||
async (req, res) => {
|
const [[row]] = await sql.query("SELECT * FROM technicaldocs WHERE id=?", [
|
||||||
const id = Number(req.params.id);
|
id,
|
||||||
await sql.query('DELETE FROM technicaldocs WHERE id=?', [id]);
|
]);
|
||||||
res.json({ ok: 1 });
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
}
|
const P = req.principal;
|
||||||
);
|
if (!P.is_superadmin && !P.inProject(row.project_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
await sql.query("DELETE FROM technicaldocs WHERE id=?", [id]);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
|
|||||||
@@ -1,204 +1,131 @@
|
|||||||
// backend/src/routes/transmittals.js (merged)
|
// FILE: backend/src/routes/transmittals.js
|
||||||
// Base: transmittals.js + list/sort/paging from transmittals-1.js
|
// ทั้งโมดูลใช้สิทธิ์เดียว: transmittals.manage (PROJECT)
|
||||||
// Notes:
|
import { Router } from "express";
|
||||||
// - ยึด RBAC/Scope/Permissions แบบเดิมของ transmittals.js
|
import sql from "../db/index.js";
|
||||||
// - Faceted list -> ส่ง meta { data, total, page, pageSize }
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
// - PATCH: อัปเดตบางฟิลด์อย่างปลอดภัย (ไม่บังคับให้มีคอลัมน์ใหม่ใน DB)
|
|
||||||
|
|
||||||
import { Router } from 'express';
|
|
||||||
import sql from '../db/index.js';
|
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
|
||||||
import { buildScopeWhere, ownerResolvers } from '../utils/scope.js';
|
|
||||||
import PERM from '../config/permissions.js';
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const OWN = ownerResolvers(sql, 'transmittals', 'id');
|
|
||||||
|
|
||||||
/* ----------------------------- Utilities ----------------------------- */
|
// LIST
|
||||||
// จำกัดฟิลด์ที่อนุญาตให้ sort เพื่อลดความเสี่ยง SQLi
|
r.get(
|
||||||
const ALLOWED_SORT = new Map([
|
"/",
|
||||||
['updated_at', 'updated_at'],
|
requirePerm("transmittals.manage", { projectParam: "project_id" }),
|
||||||
['created_at', 'created_at'],
|
|
||||||
['id', 'id'],
|
|
||||||
['tr_no', 'tr_no'],
|
|
||||||
['subject', 'subject'],
|
|
||||||
]);
|
|
||||||
function parseSort(sort = 'updated_at:desc') {
|
|
||||||
const [colRaw, dirRaw] = String(sort).split(':');
|
|
||||||
const col = ALLOWED_SORT.get(colRaw) || 'updated_at';
|
|
||||||
const dir = (dirRaw || 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
|
||||||
return `\`${col}\` ${dir}`;
|
|
||||||
}
|
|
||||||
function parsePaging({ page = 1, pageSize = 20 }) {
|
|
||||||
const p = Math.max(1, Number(page) || 1);
|
|
||||||
const ps = Math.min(200, Math.max(1, Number(pageSize) || 20));
|
|
||||||
return { limit: ps, offset: (p - 1) * ps, page: p, pageSize: ps };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildExtraFilters({ project_id, org_id, tr_no, q }) {
|
|
||||||
const extra = [];
|
|
||||||
const params = {};
|
|
||||||
if (project_id) { extra.push('t.project_id = :project_id'); params.project_id = Number(project_id); }
|
|
||||||
if (org_id) { extra.push('t.org_id = :org_id'); params.org_id = Number(org_id); }
|
|
||||||
if (tr_no) { extra.push('t.tr_no = :tr_no'); params.tr_no = tr_no; }
|
|
||||||
if (q) {
|
|
||||||
// ใช้ฟิลด์พื้นฐานที่ transmittals.js มีแน่นอน (tr_no, subject)
|
|
||||||
extra.push('(t.tr_no LIKE :q OR t.subject LIKE :q)');
|
|
||||||
params.q = `%${q}%`;
|
|
||||||
}
|
|
||||||
return { where: extra.join(' AND '), params };
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------- LIST --------------------------------
|
|
||||||
GET /transmittals
|
|
||||||
- คง RBAC/Scope เดิม (global + project/org scope ผ่าน buildScopeWhere)
|
|
||||||
- เพิ่ม sort/page/pageSize/q ตามสไตล์ transmittals-1.js และตอบ meta
|
|
||||||
------------------------------------------------------------------------*/
|
|
||||||
r.get('/',
|
|
||||||
requirePerm(PERM.transmittal.read, { scope: 'global' }),
|
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
const { project_id, tr_no, q, limit = 50, offset = 0 } = req.query;
|
||||||
const { project_id, org_id, tr_no, q, sort, page, pageSize } = req.query;
|
const P = req.principal;
|
||||||
const orderBy = parseSort(sort);
|
const cond = [];
|
||||||
const { limit, offset, page: p, pageSize: ps } = parsePaging({ page, pageSize });
|
const params = [];
|
||||||
|
|
||||||
const base = buildScopeWhere(req.principal, {
|
if (!P.is_superadmin) {
|
||||||
tableAlias: 't',
|
if (project_id) {
|
||||||
orgColumn: 't.org_id',
|
const pid = Number(project_id);
|
||||||
projectColumn: 't.project_id',
|
if (!P.inProject(pid))
|
||||||
permCode: PERM.transmittal.read,
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
preferProject: true
|
cond.push("t.project_id=?");
|
||||||
});
|
params.push(pid);
|
||||||
|
} else if (P.project_ids?.length) {
|
||||||
const extra = buildExtraFilters({ project_id, org_id, tr_no, q });
|
cond.push(
|
||||||
const where = [base.where, extra.where].filter(Boolean).join(' AND ') || '1=1';
|
`t.project_id IN (${P.project_ids.map(() => "?").join(",")})`
|
||||||
const params = { ...base.params, ...extra.params, limit, offset };
|
);
|
||||||
|
params.push(...P.project_ids);
|
||||||
// total
|
|
||||||
const [[{ cnt: total }]] = await sql.query(
|
|
||||||
`SELECT COUNT(*) AS cnt FROM transmittals t WHERE ${where}`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
|
|
||||||
// rows
|
|
||||||
const [rows] = await sql.query(
|
|
||||||
`SELECT t.*
|
|
||||||
FROM transmittals t
|
|
||||||
WHERE ${where}
|
|
||||||
ORDER BY ${orderBy}
|
|
||||||
LIMIT :limit OFFSET :offset`,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({ data: rows, total: Number(total || 0), page: p, pageSize: ps });
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: e.message || 'transmittals/list failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ------------------------------- GET ONE ------------------------------ */
|
|
||||||
r.get('/:id',
|
|
||||||
requirePerm(PERM.transmittal.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
const [[row]] = await sql.query('SELECT * FROM transmittals WHERE id=?', [id]);
|
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
|
||||||
res.json(row);
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: e.message || 'transmittals/detail failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/* -------------------------------- CREATE ------------------------------ */
|
|
||||||
r.post('/',
|
|
||||||
requirePerm(PERM.transmittal.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
// ยึดสคีมาหลักจาก transmittals.js
|
|
||||||
const { org_id, project_id, tr_no, subject, status } = req.body;
|
|
||||||
const [rs] = await sql.query(
|
|
||||||
`INSERT INTO transmittals (org_id, project_id, tr_no, subject, status, created_by)
|
|
||||||
VALUES (?,?,?,?,?,?)`,
|
|
||||||
[org_id, project_id, tr_no, subject, status, req.principal.userId]
|
|
||||||
);
|
|
||||||
res.status(201).json({ id: rs.insertId });
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: e.message || 'transmittals/create failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/* -------------------------------- UPDATE ------------------------------ */
|
|
||||||
// PUT: รูปแบบเดิม (อัปเดต subject, status)
|
|
||||||
r.put('/:id',
|
|
||||||
requirePerm(PERM.transmittal.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
const { subject, status } = req.body;
|
|
||||||
await sql.query('UPDATE transmittals SET subject=?, status=? WHERE id=?', [subject, status, id]);
|
|
||||||
res.json({ ok: 1 });
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: e.message || 'transmittals/update failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// PATCH: อัปเดตบางฟิลด์ (ไม่บังคับคอลัมน์ใหม่ใน DB — จะอัปเดตก็ต่อเมื่อ body ส่งมา)
|
|
||||||
r.patch('/:id',
|
|
||||||
requirePerm(PERM.transmittal.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
// อนุญาตเฉพาะฟิลด์ที่คาดว่ามีในสคีมาหลัก
|
|
||||||
const allowed = ['tr_no', 'subject', 'status'];
|
|
||||||
|
|
||||||
// ถ้าฐานข้อมูลของคุณมีคอลัมน์เพิ่ม เช่น to_party, sent_date, description
|
|
||||||
// และต้องการอัปเดตด้วย ให้เพิ่มชื่อคอลัมน์เข้าไปใน allowed ได้
|
|
||||||
// const allowed = ['tr_no','subject','status','to_party','sent_date','description'];
|
|
||||||
|
|
||||||
const patch = {};
|
|
||||||
for (const k of allowed) if (k in req.body) patch[k] = req.body[k];
|
|
||||||
|
|
||||||
if (Object.keys(patch).length === 0) {
|
|
||||||
return res.status(400).json({ error: 'no fields to update' });
|
|
||||||
}
|
}
|
||||||
|
} else if (project_id) {
|
||||||
if ('status' in patch) {
|
cond.push("t.project_id=?");
|
||||||
const s = String(patch.status);
|
params.push(Number(project_id));
|
||||||
const ok = ['draft','submitted','Sent','Closed','Approved','Pending','Review'].includes(s);
|
|
||||||
if (!ok) return res.status(400).json({ error: 'invalid status' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sets = Object.keys(patch).map(k => `\`${k}\` = :${k}`);
|
|
||||||
patch.id = id;
|
|
||||||
|
|
||||||
await sql.query(
|
|
||||||
`UPDATE transmittals SET ${sets.join(', ')}, updated_at = NOW() WHERE id = :id`,
|
|
||||||
patch
|
|
||||||
);
|
|
||||||
res.json({ ok: 1, id });
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ error: e.message || 'transmittals/patch failed' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tr_no) {
|
||||||
|
cond.push("t.tr_no=?");
|
||||||
|
params.push(tr_no);
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
cond.push("(t.tr_no LIKE ? OR t.subject LIKE ?)");
|
||||||
|
params.push(`%${q}%`, `%${q}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
`SELECT t.* FROM transmittals t ${where} ORDER BY t.id DESC LIMIT ? OFFSET ?`,
|
||||||
|
[...params, Number(limit), Number(offset)]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
/* -------------------------------- DELETE ------------------------------ */
|
// GET
|
||||||
r.delete('/:id',
|
r.get("/:id", requirePerm("transmittals.manage"), async (req, res) => {
|
||||||
requirePerm(PERM.transmittal.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
const id = Number(req.params.id);
|
||||||
|
const [[row]] = await sql.query("SELECT * FROM transmittals WHERE id=?", [
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const P = req.principal;
|
||||||
|
if (!P.is_superadmin && !P.inProject(row.project_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
res.json(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// CREATE
|
||||||
|
r.post(
|
||||||
|
"/",
|
||||||
|
requirePerm("transmittals.manage", { projectParam: "project_id" }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
const { org_id, project_id, tr_no, subject, status } = req.body || {};
|
||||||
const id = Number(req.params.id);
|
if (!project_id || !tr_no)
|
||||||
await sql.query('DELETE FROM transmittals WHERE id=?', [id]);
|
return res.status(400).json({ error: "project_id and tr_no required" });
|
||||||
res.json({ ok: 1 });
|
const [rs] = await sql.query(
|
||||||
} catch (e) {
|
`INSERT INTO transmittals (org_id, project_id, tr_no, subject, status, created_by)
|
||||||
res.status(500).json({ error: e.message || 'transmittals/delete failed' });
|
VALUES (?,?,?,?,?,?)`,
|
||||||
}
|
[
|
||||||
|
org_id ?? null,
|
||||||
|
project_id,
|
||||||
|
tr_no,
|
||||||
|
subject ?? null,
|
||||||
|
status ?? null,
|
||||||
|
req.principal.user_id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
res.status(201).json({ id: rs.insertId });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// UPDATE
|
||||||
|
r.patch("/:id", requirePerm("transmittals.manage"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const [[row]] = await sql.query("SELECT * FROM transmittals WHERE id=?", [
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const P = req.principal;
|
||||||
|
if (!P.is_superadmin && !P.inProject(row.project_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
|
||||||
|
const allowed = ["tr_no", "subject", "status"];
|
||||||
|
const patch = {};
|
||||||
|
for (const k of allowed) if (k in req.body) patch[k] = req.body[k];
|
||||||
|
if (!Object.keys(patch).length)
|
||||||
|
return res.status(400).json({ error: "no fields to update" });
|
||||||
|
|
||||||
|
const sets = Object.keys(patch).map((k) => `\`${k}\`=?`);
|
||||||
|
await sql.query(`UPDATE transmittals SET ${sets.join(", ")} WHERE id=?`, [
|
||||||
|
...Object.values(patch),
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
r.delete("/:id", requirePerm("transmittals.manage"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const [[row]] = await sql.query("SELECT * FROM transmittals WHERE id=?", [
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
|
const P = req.principal;
|
||||||
|
if (!P.is_superadmin && !P.inProject(row.project_id))
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
await sql.query("DELETE FROM transmittals WHERE id=?", [id]);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
|
|||||||
@@ -1,67 +1,108 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/uploads.js
|
||||||
import multer from 'multer';
|
// อัปโหลดไฟล์ผูกกับโมดูล (PROJECT scope): documents/drawings/correspondences/rfas/transmittals
|
||||||
import fs from 'node:fs';
|
import { Router } from "express";
|
||||||
import path from 'node:path';
|
import multer from "multer";
|
||||||
import sql from '../db/index.js';
|
import fs from "node:fs";
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
import path from "node:path";
|
||||||
import PERM from '../config/permissions.js';
|
import sql from "../db/index.js";
|
||||||
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const UPLOAD_BASE = process.env.UPLOAD_BASE || '/share/dms-data';
|
const UPLOAD_BASE = process.env.UPLOAD_BASE || "/share/dms-data";
|
||||||
function ensureDir(p) { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }
|
const ensureDir = (p) => {
|
||||||
|
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchRef(module, id) {
|
||||||
const storage = multer.diskStorage({
|
const tbl = String(module);
|
||||||
destination: async (req, file, cb) => {
|
const idCol = "id";
|
||||||
try {
|
const [[row]] = await sql.query(
|
||||||
const { module, id } = req.params;
|
`SELECT org_id, project_id, created_at FROM ${tbl} WHERE ${idCol}=?`,
|
||||||
const [[row]] = await sql.query(`SELECT org_id, project_id, created_at FROM ${module} WHERE id=?`, [Number(id)]);
|
[Number(id)]
|
||||||
if (!row) return cb(new Error('Resource not found'));
|
);
|
||||||
const dt = new Date(row.created_at || Date.now());
|
return row || null;
|
||||||
const ym = `${dt.getUTCFullYear()}-${String(dt.getUTCMonth()+1).padStart(2,'0')}`;
|
|
||||||
const dir = path.join(UPLOAD_BASE, module, String(row.org_id), String(row.project_id), ym);
|
|
||||||
ensureDir(dir);
|
|
||||||
cb(null, dir);
|
|
||||||
} catch (e) { cb(e); }
|
|
||||||
},
|
|
||||||
filename: (req, file, cb) => {
|
|
||||||
const ts = Date.now();
|
|
||||||
const safe = file.originalname.replace(/[\^\w.\-]+/g, '_');
|
|
||||||
cb(null, `${ts}__${safe}`);
|
|
||||||
}
|
}
|
||||||
|
function sanitize(name) {
|
||||||
|
// แทนที่อักขระไม่ปลอดภัย
|
||||||
|
return String(name).replace(/[^A-Za-z0-9._-]+/g, "_");
|
||||||
|
}
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: async (req, _file, cb) => {
|
||||||
|
try {
|
||||||
|
const { module, id } = req.params;
|
||||||
|
const ref = await fetchRef(module, id);
|
||||||
|
if (!ref) return cb(new Error("Resource not found"));
|
||||||
|
const dt = new Date(ref.created_at || Date.now());
|
||||||
|
const ym = `${dt.getUTCFullYear()}-${String(
|
||||||
|
dt.getUTCMonth() + 1
|
||||||
|
).padStart(2, "0")}`;
|
||||||
|
const dir = path.join(
|
||||||
|
UPLOAD_BASE,
|
||||||
|
module,
|
||||||
|
String(ref.org_id || "0"),
|
||||||
|
String(ref.project_id || "0"),
|
||||||
|
ym
|
||||||
|
);
|
||||||
|
ensureDir(dir);
|
||||||
|
cb(null, dir);
|
||||||
|
} catch (e) {
|
||||||
|
cb(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filename: (_req, file, cb) =>
|
||||||
|
cb(null, `${Date.now()}__${sanitize(file.originalname)}`),
|
||||||
});
|
});
|
||||||
const upload = multer({ storage });
|
const upload = multer({ storage });
|
||||||
|
|
||||||
|
// map module -> permission
|
||||||
const PERM_UPLOAD = {
|
function uploadPerm(module) {
|
||||||
correspondences: PERM.correspondence.upload,
|
if (module === "documents") return "documents.manage";
|
||||||
rfas: PERM.rfa.upload,
|
if (module === "drawings") return "drawings.upload";
|
||||||
drawings: PERM.drawing.upload,
|
if (module === "correspondences") return "corr.manage";
|
||||||
transmittals: PERM.transmittal?.upload,
|
if (module === "rfas") return "rfas.respond";
|
||||||
};
|
if (module === "transmittals") return "transmittals.manage";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
async function getProjectIdByModule(req){
|
async function refProjectId(module, id) {
|
||||||
const { module, id } = req.params;
|
const [[row]] = await sql.query(
|
||||||
const [[row]] = await sql.query(`SELECT project_id FROM ${module} WHERE id=?`, [Number(id)]);
|
`SELECT project_id FROM ${module} WHERE id=?`,
|
||||||
return row?.project_id ?? null;
|
[Number(id)]
|
||||||
|
);
|
||||||
|
return row?.project_id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
r.post(
|
||||||
r.post('/:module/:id/file',
|
"/:module/:id/file",
|
||||||
(req, res, next) => {
|
(req, res, next) => {
|
||||||
const perm = PERM_UPLOAD[req.params.module];
|
const perm = uploadPerm(req.params.module);
|
||||||
if (!perm) return res.status(400).json({ error: 'Unsupported module' });
|
if (!perm) return res.status(400).json({ error: "Unsupported module" });
|
||||||
return requirePerm(perm, { scope: 'project', getProjectId: getProjectIdByModule })(req, res, next);
|
return requirePerm(perm, { projectParam: undefined })(req, res, next);
|
||||||
},
|
},
|
||||||
upload.single('file'),
|
async (req, res, next) => {
|
||||||
async (req, res) => {
|
// ABAC: ตรวจ project scope ของ record
|
||||||
const { module, id } = req.params;
|
const pid = await refProjectId(req.params.module, req.params.id);
|
||||||
const file = req.file;
|
if (
|
||||||
res.json({ ok: 1, module, ref_id: Number(id), filename: file.filename, path: file.path, size: file.size, mime: file.mimetype });
|
!req.principal.is_superadmin &&
|
||||||
}
|
(!pid || !req.principal.inProject(pid))
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
upload.single("file"),
|
||||||
|
async (req, res) => {
|
||||||
|
const { module, id } = req.params;
|
||||||
|
const file = req.file;
|
||||||
|
res.json({
|
||||||
|
ok: 1,
|
||||||
|
module,
|
||||||
|
ref_id: Number(id),
|
||||||
|
filename: file.filename,
|
||||||
|
path: file.path,
|
||||||
|
size: file.size,
|
||||||
|
mime: file.mimetype,
|
||||||
|
});
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
55
backend/src/routes/users copy.js
Normal file
55
backend/src/routes/users copy.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// FILE: backend/src/routes/users.js
|
||||||
|
import { Router } from "express";
|
||||||
|
import sql from "../db/index.js";
|
||||||
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
|
|
||||||
|
const r = Router();
|
||||||
|
|
||||||
|
// ME (ทุกคน)
|
||||||
|
r.get("/me", async (req, res) => {
|
||||||
|
const p = req.principal;
|
||||||
|
const [[u]] = await sql.query(
|
||||||
|
`SELECT user_id, username, email, first_name, last_name, org_id FROM users WHERE user_id=?`,
|
||||||
|
[p.user_id]
|
||||||
|
);
|
||||||
|
if (!u) return res.status(404).json({ error: "User not found" });
|
||||||
|
const [roles] = await sql.query(
|
||||||
|
`SELECT r.role_code, r.role_name, ur.org_id, ur.project_id
|
||||||
|
FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id
|
||||||
|
WHERE ur.user_id=?`,
|
||||||
|
[p.user_id]
|
||||||
|
);
|
||||||
|
res.json({
|
||||||
|
...u,
|
||||||
|
roles,
|
||||||
|
role_codes: roles.map((r) => r.role_code),
|
||||||
|
permissions: [...(p.permissions || [])],
|
||||||
|
project_ids: p.project_ids,
|
||||||
|
org_ids: p.org_ids,
|
||||||
|
is_superadmin: p.is_superadmin,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// USERS LIST (ORG scope) — admin.access
|
||||||
|
r.get(
|
||||||
|
"/",
|
||||||
|
requirePerm("admin.access", { orgParam: "org_id" }),
|
||||||
|
async (req, res) => {
|
||||||
|
const P = req.principal;
|
||||||
|
let rows = [];
|
||||||
|
if (P.is_superadmin) {
|
||||||
|
[rows] = await sql.query(
|
||||||
|
"SELECT user_id, username, email, org_id FROM users ORDER BY user_id DESC LIMIT 500"
|
||||||
|
);
|
||||||
|
} else if (P.org_ids?.length) {
|
||||||
|
const inSql = P.org_ids.map(() => "?").join(",");
|
||||||
|
[rows] = await sql.query(
|
||||||
|
`SELECT user_id, username, email, org_id FROM users WHERE org_id IN (${inSql}) ORDER BY user_id DESC LIMIT 500`,
|
||||||
|
P.org_ids
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.json(rows);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default r;
|
||||||
65
backend/src/routes/users.js
Normal file → Executable file
65
backend/src/routes/users.js
Normal file → Executable file
@@ -1,30 +1,53 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/users.js
|
||||||
import sql from '../db/index.js';
|
import { Router } from "express";
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
import sql from "../db/index.js";
|
||||||
import PERM from '../config/permissions.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
|
|
||||||
// ME
|
// ME (ทุกคน)
|
||||||
r.get('/me', async (req, res) => {
|
r.get("/me", async (req, res) => {
|
||||||
const [[u]] = await sql.query('SELECT user_id, username, email, first_name, last_name FROM users WHERE user_id=?',
|
const p = req.principal;
|
||||||
[req.principal.userId]);
|
const [[u]] = await sql.query(
|
||||||
if (!u) return res.status(404).json({ error: 'User not found' });
|
`SELECT user_id, username, email, first_name, last_name, org_id FROM users WHERE user_id=?`,
|
||||||
|
[p.user_id]
|
||||||
// roles in plain
|
);
|
||||||
const [roles] = await sql.query(`
|
if (!u) return res.status(404).json({ error: "User not found" });
|
||||||
SELECT r.role_code, r.role_name, ur.org_id, ur.project_id
|
const [roles] = await sql.query(
|
||||||
FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id
|
`SELECT r.role_code, r.role_name, ur.org_id, ur.project_id
|
||||||
WHERE ur.user_id=?`, [req.principal.userId]);
|
FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id
|
||||||
|
WHERE ur.user_id=?`,
|
||||||
res.json({ ...u, roles, role_codes: [...req.principal.roleCodes] });
|
[p.user_id]
|
||||||
|
);
|
||||||
|
res.json({
|
||||||
|
...u,
|
||||||
|
roles,
|
||||||
|
role_codes: roles.map((r) => r.role_code),
|
||||||
|
permissions: [...(p.permissions || [])],
|
||||||
|
project_ids: p.project_ids,
|
||||||
|
org_ids: p.org_ids,
|
||||||
|
is_superadmin: p.is_superadmin,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// (optional) USERS LIST – ให้เฉพาะ SUPER_ADMIN หรือ ADMIN (ใน org ตัวเอง)
|
// USERS LIST (ORG scope) — admin.access
|
||||||
r.get('/',
|
r.get(
|
||||||
requirePerm('user.read', { scope: 'global' }),
|
"/",
|
||||||
|
requirePerm("admin.access", { orgParam: "org_id" }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const [rows] = await sql.query('SELECT user_id, username, email FROM users LIMIT 200');
|
const P = req.principal;
|
||||||
|
let rows = [];
|
||||||
|
if (P.is_superadmin) {
|
||||||
|
[rows] = await sql.query(
|
||||||
|
"SELECT user_id, username, email, org_id FROM users ORDER BY user_id DESC LIMIT 500"
|
||||||
|
);
|
||||||
|
} else if (P.org_ids?.length) {
|
||||||
|
const inSql = P.org_ids.map(() => "?").join(",");
|
||||||
|
[rows] = await sql.query(
|
||||||
|
`SELECT user_id, username, email, org_id FROM users WHERE org_id IN (${inSql}) ORDER BY user_id DESC LIMIT 500`,
|
||||||
|
P.org_ids
|
||||||
|
);
|
||||||
|
}
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,55 +1,39 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/users_extras.js
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
// NOTE: ของเดิมใช้ cookie + Sequelize -> ปรับให้อยู่หลัง Bearer stack และจำกัดความสามารถ
|
||||||
import { requireRole } from '../middleware/rbac.js';
|
import { Router } from "express";
|
||||||
import { User } from '../db/sequelize.js';
|
import sql from "../db/index.js";
|
||||||
import { hashPassword } from '../utils/passwords.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
import { sequelize } from '../db/sequelize.js';
|
|
||||||
import UPRModel from '../db/models/UserProjectRole.js';
|
|
||||||
import ProjectModel from '../db/models/Project.js';
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const UPR = UPRModel(sequelize);
|
|
||||||
const Project = ProjectModel(sequelize);
|
|
||||||
|
|
||||||
// self or admin change password
|
/**
|
||||||
r.patch('/users/:id/password', requireAuth, async (req, res) => {
|
* PATCH /users/:id/password
|
||||||
const targetId = Number(req.params.id);
|
* เฉพาะผู้มี settings.manage (GLOBAL) — (คำเตือน: ต้องมีระบบ hash/rotate ที่ service auth)
|
||||||
const isSelf = req.user?.user_id === targetId;
|
*/
|
||||||
const isAdmin = (req.user?.roles || []).includes('Admin');
|
r.patch(
|
||||||
if (!isSelf && !isAdmin) return res.status(403).json({ error: 'Forbidden' });
|
"/users/:id/password",
|
||||||
|
requirePerm("settings.manage"),
|
||||||
|
async (_req, res) => {
|
||||||
|
// ในโปรเจคนี้การเปลี่ยนรหัสผ่านควรวิ่งที่ auth service/procedure โดยเฉพาะ
|
||||||
|
return res
|
||||||
|
.status(501)
|
||||||
|
.json({ error: "Not implemented here. Use auth service." });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const { new_password } = req.body || {};
|
/**
|
||||||
if (!new_password) return res.status(400).json({ error: 'new_password required' });
|
* GET /users/me/projects — สรุปโปรเจ็ค/บทบาทของผู้ใช้
|
||||||
|
*/
|
||||||
const row = await User.findByPk(targetId);
|
r.get("/users/me/projects", async (req, res) => {
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
const uid = req.principal.user_id;
|
||||||
|
const [rows] = await sql.query(
|
||||||
row.password_hash = await hashPassword(new_password);
|
`SELECT upr.project_id, r.role_code, r.role_name
|
||||||
await row.save();
|
FROM user_project_roles upr
|
||||||
res.json({ ok: true });
|
JOIN roles r ON r.role_id = upr.role_id
|
||||||
});
|
WHERE upr.user_id=? ORDER BY upr.project_id`,
|
||||||
|
[uid]
|
||||||
// user search (autocomplete)
|
);
|
||||||
r.get('/users/search', requireAuth, requireRole('Admin'), async (req, res) => {
|
|
||||||
const q = String(req.query.q || '').toLowerCase();
|
|
||||||
const where = q ? {
|
|
||||||
username: sequelize.where(sequelize.fn('LOWER', sequelize.col('username')), 'LIKE', `%${q}%`),
|
|
||||||
} : {};
|
|
||||||
const rows = await User.findAll({ where, limit: 20, order:[['username','ASC']], attributes:['user_id','username','first_name','last_name','email'] });
|
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
// my projects/roles
|
|
||||||
r.get('/users/me/projects', requireAuth, async (req, res) => {
|
|
||||||
const user_id = req.user?.user_id;
|
|
||||||
if (!user_id) return res.status(401).json({ error: 'Unauthorized' });
|
|
||||||
const rows = await UPR.findAll({ where: { user_id } });
|
|
||||||
// Optionally join project names
|
|
||||||
const projectIds = [...new Set(rows.map(r => r.project_id))];
|
|
||||||
const projects = await Project.findAll({ where: { project_id: projectIds } });
|
|
||||||
const map = new Map(projects.map(p => [p.project_id, p.project_name]));
|
|
||||||
const result = rows.map(r => ({ project_id: r.project_id, role_name: r.role_name, project_name: map.get(r.project_id) || null }));
|
|
||||||
res.json(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
|
|||||||
@@ -1,100 +1,100 @@
|
|||||||
// src/routes/view.js
|
// FILE: backend/src/routes/view.js
|
||||||
import { Router } from 'express';
|
// Saved Views: อ่านด้วย reports.view (GLOBAL); เขียนด้วย settings.manage
|
||||||
import sql from '../db/index.js';
|
import { Router } from "express";
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
import sql from "../db/index.js";
|
||||||
import { buildScopeWhere, ownerResolvers } from '../utils/scope.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
import PERM from '../config/permissions.js';
|
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const OWN = ownerResolvers(sql, 'saved_views', 'id');
|
|
||||||
|
|
||||||
// LIST: GET /api/view?project_id=&org_id=&shared=1
|
// LIST (ทุกคนที่มี reports.view)
|
||||||
r.get('/',
|
r.get("/", requirePerm("reports.view"), async (req, res) => {
|
||||||
requirePerm(PERM.savedview.read, { scope: 'global' }),
|
const { project_id, shared = "1", q, limit = 50, offset = 0 } = req.query;
|
||||||
async (req, res) => {
|
const p = req.principal;
|
||||||
const { project_id, org_id, shared, q, limit = 50, offset = 0 } = req.query;
|
const cond = [];
|
||||||
|
const params = [];
|
||||||
const base = buildScopeWhere(req.principal, {
|
// ให้เห็นของตัวเองเสมอ + shared
|
||||||
tableAlias: 'v',
|
cond.push("(v.is_shared=1 OR v.owner_user_id=?)");
|
||||||
orgColumn: 'v.org_id',
|
params.push(p.user_id);
|
||||||
projectColumn: 'v.project_id',
|
if (project_id) {
|
||||||
permCode: PERM.savedview.read,
|
cond.push("v.project_id=?");
|
||||||
preferProject: true,
|
params.push(Number(project_id));
|
||||||
});
|
|
||||||
|
|
||||||
const extra = [];
|
|
||||||
const params = { ...base.params, limit: Number(limit), offset: Number(offset), my: req.principal.userId };
|
|
||||||
if (project_id) { extra.push('v.project_id = :project_id'); params.project_id = Number(project_id); }
|
|
||||||
if (org_id) { extra.push('v.org_id = :org_id'); params.org_id = Number(org_id); }
|
|
||||||
if (shared === '1') extra.push('v.is_shared = 1');
|
|
||||||
if (q) { extra.push('(v.name LIKE :q)'); params.q = `%${q}%`; }
|
|
||||||
|
|
||||||
// ให้ผู้ใช้เห็นของตัวเองเสมอ + ของที่อยู่ใน scope
|
|
||||||
const where = `(${base.where}) AND (v.is_shared=1 OR v.owner_user_id=:my${extra.length ? ' OR ' + extra.join(' AND ') : ''})`;
|
|
||||||
|
|
||||||
const [rows] = await sql.query(
|
|
||||||
`SELECT v.* FROM saved_views v
|
|
||||||
WHERE ${where}
|
|
||||||
ORDER BY v.id DESC
|
|
||||||
LIMIT :limit OFFSET :offset`, params
|
|
||||||
);
|
|
||||||
res.json(rows);
|
|
||||||
}
|
}
|
||||||
);
|
if (q) {
|
||||||
|
cond.push("v.name LIKE ?");
|
||||||
// GET by id
|
params.push(`%${q}%`);
|
||||||
r.get('/:id',
|
|
||||||
requirePerm(PERM.savedview.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
|
||||||
async (req, res) => {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
const [[row]] = await sql.query('SELECT * FROM saved_views WHERE id=?', [id]);
|
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
|
||||||
res.json(row);
|
|
||||||
}
|
}
|
||||||
);
|
if (shared === "0") {
|
||||||
|
cond.push("v.is_shared=0");
|
||||||
// CREATE
|
|
||||||
r.post('/',
|
|
||||||
requirePerm(PERM.savedview.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
|
|
||||||
async (req, res) => {
|
|
||||||
const { org_id, project_id, name, payload_json, is_shared = 0 } = req.body;
|
|
||||||
const [rs] = await sql.query(
|
|
||||||
`INSERT INTO saved_views (org_id, project_id, name, payload_json, is_shared, owner_user_id)
|
|
||||||
VALUES (?,?,?,?,?,?)`,
|
|
||||||
[org_id, project_id, name, JSON.stringify(payload_json ?? {}), Number(is_shared) ? 1 : 0, req.principal.userId]
|
|
||||||
);
|
|
||||||
res.json({ id: rs.insertId });
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
// UPDATE (เฉพาะใน scope และถ้าเป็นของตนเอง หรือเป็นแอดมินตามนโยบาย)
|
const where = `WHERE ${cond.join(" AND ")}`;
|
||||||
r.put('/:id',
|
const [rows] = await sql.query(
|
||||||
requirePerm(PERM.savedview.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
`SELECT v.* FROM saved_views v ${where} ORDER BY v.id DESC LIMIT ? OFFSET ?`,
|
||||||
async (req, res) => {
|
[...params, Number(limit), Number(offset)]
|
||||||
const id = Number(req.params.id);
|
);
|
||||||
const { name, payload_json, is_shared } = req.body;
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
// ตรวจ owner ถ้าต้องการบังคับเป็นของตัวเอง (option)
|
// GET
|
||||||
const [[sv]] = await sql.query('SELECT owner_user_id FROM saved_views WHERE id=?', [id]);
|
r.get("/:id", requirePerm("reports.view"), async (req, res) => {
|
||||||
if (!sv) return res.status(404).json({ error: 'Not found' });
|
const id = Number(req.params.id);
|
||||||
// ถ้าจะจำกัดเฉพาะเจ้าของ: if (sv.owner_user_id !== req.principal.userId && !req.principal.isSuperAdmin) return res.status(403).json({ error: 'NOT_OWNER' });
|
const [[row]] = await sql.query("SELECT * FROM saved_views WHERE id=?", [id]);
|
||||||
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
await sql.query(
|
if (
|
||||||
'UPDATE saved_views SET name=?, payload_json=?, is_shared=? WHERE id=?',
|
!(
|
||||||
[name, JSON.stringify(payload_json ?? {}), Number(is_shared) ? 1 : 0, id]
|
row.is_shared ||
|
||||||
);
|
row.owner_user_id === req.principal.user_id ||
|
||||||
res.json({ ok: 1 });
|
req.principal.is_superadmin
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: "FORBIDDEN" });
|
||||||
}
|
}
|
||||||
);
|
res.json(row);
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE
|
// CREATE / UPDATE / DELETE (ต้องมี settings.manage)
|
||||||
r.delete('/:id',
|
r.post("/", requirePerm("settings.manage"), async (req, res) => {
|
||||||
requirePerm(PERM.savedview.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
|
const {
|
||||||
async (req, res) => {
|
org_id,
|
||||||
const id = Number(req.params.id);
|
project_id,
|
||||||
await sql.query('DELETE FROM saved_views WHERE id=?', [id]);
|
name,
|
||||||
res.json({ ok: 1 });
|
payload_json,
|
||||||
}
|
is_shared = 0,
|
||||||
);
|
} = req.body || {};
|
||||||
|
const [rs] = await sql.query(
|
||||||
|
`INSERT INTO saved_views (org_id, project_id, name, payload_json, is_shared, owner_user_id)
|
||||||
|
VALUES (?,?,?,?,?,?)`,
|
||||||
|
[
|
||||||
|
org_id ?? null,
|
||||||
|
project_id ?? null,
|
||||||
|
name ?? "",
|
||||||
|
JSON.stringify(payload_json ?? {}),
|
||||||
|
Number(is_shared) ? 1 : 0,
|
||||||
|
req.principal.user_id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
res.status(201).json({ id: rs.insertId });
|
||||||
|
});
|
||||||
|
|
||||||
|
r.put("/:id", requirePerm("settings.manage"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const { name, payload_json, is_shared } = req.body || {};
|
||||||
|
await sql.query(
|
||||||
|
"UPDATE saved_views SET name=?, payload_json=?, is_shared=? WHERE id=?",
|
||||||
|
[
|
||||||
|
name ?? null,
|
||||||
|
JSON.stringify(payload_json ?? {}),
|
||||||
|
Number(is_shared) ? 1 : 0,
|
||||||
|
id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
r.delete("/:id", requirePerm("settings.manage"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
await sql.query("DELETE FROM saved_views WHERE id=?", [id]);
|
||||||
|
res.json({ ok: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
|
|||||||
@@ -1,39 +1,32 @@
|
|||||||
// src/routes/views.js (ESM)
|
// FILE: backend/src/routes/views.js
|
||||||
import { Router } from 'express';
|
// จำกัดเฉพาะแอดมินระบบ: settings.manage
|
||||||
import sql from '../db/index.js';
|
import { Router } from "express";
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
import sql from "../db/index.js";
|
||||||
import PERM from '../config/permissions.js';
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
const DB_NAME = process.env.DB_NAME || 'dms_db';
|
const DB_NAME = process.env.DB_NAME || "dms_db";
|
||||||
|
|
||||||
// LIST views
|
r.get("/", requirePerm("settings.manage"), async (_req, res) => {
|
||||||
r.get('/',
|
const [rows] = await sql.query(
|
||||||
requirePerm(PERM.viewdef.read, { scope: 'global' }),
|
`SELECT TABLE_SCHEMA AS db, TABLE_NAME AS view_name
|
||||||
async (req, res) => {
|
|
||||||
const [rows] = await sql.query(
|
|
||||||
`SELECT TABLE_SCHEMA AS db, TABLE_NAME AS view_name
|
|
||||||
FROM information_schema.VIEWS
|
FROM information_schema.VIEWS
|
||||||
WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME`, [DB_NAME]
|
WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME`,
|
||||||
);
|
[DB_NAME]
|
||||||
res.json(rows);
|
);
|
||||||
}
|
res.json(rows);
|
||||||
);
|
});
|
||||||
|
|
||||||
// GET view definition
|
r.get("/:view_name", requirePerm("settings.manage"), async (req, res) => {
|
||||||
r.get('/:view_name',
|
const viewName = req.params.view_name;
|
||||||
requirePerm(PERM.viewdef.read, { scope: 'global' }),
|
const [[row]] = await sql.query(
|
||||||
async (req, res) => {
|
`SELECT VIEW_DEFINITION AS definition
|
||||||
const viewName = req.params.view_name;
|
|
||||||
const [[row]] = await sql.query(
|
|
||||||
`SELECT VIEW_DEFINITION AS definition
|
|
||||||
FROM information_schema.VIEWS
|
FROM information_schema.VIEWS
|
||||||
WHERE TABLE_SCHEMA=? AND TABLE_NAME=?`,
|
WHERE TABLE_SCHEMA=? AND TABLE_NAME=?`,
|
||||||
[DB_NAME, viewName]
|
[DB_NAME, viewName]
|
||||||
);
|
);
|
||||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
if (!row) return res.status(404).json({ error: "Not found" });
|
||||||
res.json({ view: viewName, definition: row.definition });
|
res.json({ view: viewName, definition: row.definition });
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
|
|||||||
@@ -1,50 +1,55 @@
|
|||||||
import { Router } from 'express';
|
// FILE: backend/src/routes/volumes.js
|
||||||
import sql from '../db/index.js';
|
// Master data: volumes
|
||||||
import { requirePerm } from '../middleware/requirePerm.js';
|
// - Read: organizations.view (GLOBAL)
|
||||||
import PERM from '../config/permissions.js';
|
// - Write: settings.manage (GLOBAL)
|
||||||
|
|
||||||
|
import { Router } from "express";
|
||||||
|
import sql from "../db/index.js";
|
||||||
|
import { requirePerm } from "../middleware/requirePerm.js";
|
||||||
|
|
||||||
const r = Router();
|
const r = Router();
|
||||||
|
|
||||||
|
// LIST
|
||||||
|
r.get("/", requirePerm("organizations.view"), async (_req, res) => {
|
||||||
|
const [rows] = await sql.query(
|
||||||
|
"SELECT volume_id, volume_code, volume_name FROM volumes ORDER BY volume_code ASC"
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
r.get('/',
|
// CREATE
|
||||||
requirePerm(PERM.volume.read, { scope: 'global' }),
|
r.post("/", requirePerm("settings.manage"), async (req, res) => {
|
||||||
async (req, res) => {
|
const { volume_code, volume_name } = req.body || {};
|
||||||
const [rows] = await sql.query('SELECT * FROM volumes ORDER BY volume_id DESC');
|
if (!volume_code || !volume_name) {
|
||||||
res.json(rows);
|
return res
|
||||||
}
|
.status(400)
|
||||||
);
|
.json({ error: "volume_code and volume_name required" });
|
||||||
|
}
|
||||||
|
const [rs] = await sql.query(
|
||||||
|
"INSERT INTO volumes (volume_code, volume_name) VALUES (?, ?)",
|
||||||
|
[volume_code, volume_name]
|
||||||
|
);
|
||||||
|
res.status(201).json({ volume_id: rs.insertId });
|
||||||
|
});
|
||||||
|
|
||||||
|
// UPDATE
|
||||||
|
r.put("/:id", requirePerm("settings.manage"), async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const { volume_name } = req.body || {};
|
||||||
|
if (!volume_name)
|
||||||
|
return res.status(400).json({ error: "volume_name required" });
|
||||||
|
await sql.query("UPDATE volumes SET volume_name=? WHERE volume_id=?", [
|
||||||
|
volume_name,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
r.post('/',
|
// DELETE
|
||||||
requirePerm(PERM.volume.create, { scope: 'global' }),
|
r.delete("/:id", requirePerm("settings.manage"), async (req, res) => {
|
||||||
async (req, res) => {
|
const id = Number(req.params.id);
|
||||||
const { volume_code, volume_name } = req.body;
|
await sql.query("DELETE FROM volumes WHERE volume_id=?", [id]);
|
||||||
const [rs] = await sql.query('INSERT INTO volumes (volume_code, volume_name) VALUES (?,?)', [volume_code, volume_name]);
|
res.json({ ok: true });
|
||||||
res.json({ volume_id: rs.insertId });
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
r.put('/:id',
|
|
||||||
requirePerm(PERM.volume.update, { scope: 'global' }),
|
|
||||||
async (req, res) => {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
const { volume_name } = req.body;
|
|
||||||
await sql.query('UPDATE volumes SET volume_name=? WHERE volume_id=?', [volume_name, id]);
|
|
||||||
res.json({ ok: 1 });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
r.delete('/:id',
|
|
||||||
requirePerm(PERM.volume.delete, { scope: 'global' }),
|
|
||||||
async (req, res) => {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
await sql.query('DELETE FROM volumes WHERE volume_id=?', [id]);
|
|
||||||
res.json({ ok: 1 });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
export default r;
|
export default r;
|
||||||
17
backend/src/utils/cookie.js
Normal file
17
backend/src/utils/cookie.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// FILE: backend/src/utils/cookie.js
|
||||||
|
export const cookieOpts = (maxAgeMs) => {
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
const opts = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true, // หลัง Nginx/HTTPS
|
||||||
|
sameSite: "none", // ส่งข้าม subdomain ได้
|
||||||
|
path: "/",
|
||||||
|
maxAge: maxAgeMs,
|
||||||
|
};
|
||||||
|
if (process.env.COOKIE_DOMAIN) opts.domain = process.env.COOKIE_DOMAIN; // เช่น .np-dms.work
|
||||||
|
if (!isProd && process.env.ALLOW_INSECURE_COOKIE === "1") {
|
||||||
|
opts.secure = false;
|
||||||
|
opts.sameSite = "lax";
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
};
|
||||||
31
backend/src/utils/jwt.js
Normal file
31
backend/src/utils/jwt.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// FILE: backend/src/utils/jwt.js
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || "dev_access_secret";
|
||||||
|
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || "dev_refresh_secret";
|
||||||
|
|
||||||
|
export const ACCESS_TTL_MS = parseInt(
|
||||||
|
process.env.ACCESS_TTL_MS || `${15 * 60 * 1000}`,
|
||||||
|
10
|
||||||
|
); // 15 นาที
|
||||||
|
export const REFRESH_TTL_MS = parseInt(
|
||||||
|
process.env.REFRESH_TTL_MS || `${7 * 24 * 60 * 60 * 1000}`,
|
||||||
|
10
|
||||||
|
); // 7 วัน
|
||||||
|
|
||||||
|
export function signAccessToken(payload) {
|
||||||
|
return jwt.sign(payload, ACCESS_SECRET, {
|
||||||
|
expiresIn: Math.floor(ACCESS_TTL_MS / 1000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function signRefreshToken(payload) {
|
||||||
|
return jwt.sign(payload, REFRESH_SECRET, {
|
||||||
|
expiresIn: Math.floor(REFRESH_TTL_MS / 1000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function verifyAccessToken(token) {
|
||||||
|
return jwt.verify(token, ACCESS_SECRET, { clockTolerance: 10 }); // เผื่อเวลา QNAP คลาด
|
||||||
|
}
|
||||||
|
export function verifyRefreshToken(token) {
|
||||||
|
return jwt.verify(token, REFRESH_SECRET, { clockTolerance: 10 });
|
||||||
|
}
|
||||||
@@ -1,4 +1,14 @@
|
|||||||
import bcrypt from 'bcrypt';
|
// FILE: backend/src/utils/passwords.js
|
||||||
|
// Password hashing and verification utilities
|
||||||
|
// - Uses bcrypt for secure password hashing
|
||||||
|
// - Provides hashPassword(plain) and verifyPassword(plain, hash) functions
|
||||||
|
// - hashPassword returns a promise that resolves to the hashed password
|
||||||
|
// - verifyPassword returns a promise that resolves to true/false
|
||||||
|
// - Uses 10 salt rounds for hashing
|
||||||
|
// - Assumes bcrypt package is installed
|
||||||
|
// - Suitable for user authentication systems
|
||||||
|
// - Can be used in user registration and login flows
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
export async function hashPassword(plain) {
|
export async function hashPassword(plain) {
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
return bcrypt.hash(plain, saltRounds);
|
return bcrypt.hash(plain, saltRounds);
|
||||||
|
|||||||
@@ -1,51 +1,69 @@
|
|||||||
// src/utils/rbac.js
|
// FILE: backend/src/utils/rbac.js
|
||||||
import sql from '../db/index.js';
|
// 03.2 2) เพิ่มตัวช่วย RBAC (ใหม่)
|
||||||
|
// Role-Based Access Control (RBAC) utilities
|
||||||
|
// - loadPrincipal(userId) to load user's roles, permissions, orgs, projects
|
||||||
|
// - canPerform(principal, permCode, {scope, orgId, projectId}) to check permission
|
||||||
|
// - Uses raw SQL queries via db/index.js
|
||||||
|
// - Permissions can be global, org-scoped, or project-scoped
|
||||||
|
// - Admin roles have special handling for org/project scope
|
||||||
|
// - SUPER_ADMIN bypasses all checks
|
||||||
|
|
||||||
|
import sql from "../db/index.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* โหลด principal (บทบาท, องค์กร, โปรเจกต์) ให้ผู้ใช้
|
* โหลด principal (บทบาท, องค์กร, โปรเจกต์) ให้ผู้ใช้
|
||||||
*/
|
*/
|
||||||
export async function loadPrincipal(userId) {
|
export async function loadPrincipal(userId) {
|
||||||
const [rolesRows] = await sql.query(/*sql*/`
|
const [rolesRows] = await sql.query(
|
||||||
SELECT ur.user_id, r.role_code, ur.org_id, ur.project_id
|
/*sql*/ `
|
||||||
FROM user_roles ur
|
SELECT ur.user_id, r.role_code, ur.org_id, ur.project_id
|
||||||
JOIN roles r ON r.role_id = ur.role_id
|
FROM user_roles ur
|
||||||
WHERE ur.user_id = ?
|
JOIN roles r ON r.role_id = ur.role_id
|
||||||
`, [userId]);
|
WHERE ur.user_id = ?
|
||||||
|
`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [permRows] = await sql.query(
|
||||||
|
/*sql*/ `
|
||||||
|
SELECT ur.user_id, r.role_code, p.permission_code, ur.org_id, ur.project_id
|
||||||
|
FROM user_roles ur
|
||||||
|
JOIN roles r ON r.role_id = ur.role_id
|
||||||
|
JOIN role_permissions rp ON rp.role_id = r.role_id
|
||||||
|
JOIN permissions p ON p.permission_id = rp.permission_id
|
||||||
|
WHERE ur.user_id = ?
|
||||||
|
`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
const [permRows] = await sql.query(/*sql*/`
|
const roleCodes = new Set(rolesRows.map((r) => r.role_code));
|
||||||
SELECT ur.user_id, r.role_code, p.permission_code, ur.org_id, ur.project_id
|
const isSuperAdmin = roleCodes.has("SUPER_ADMIN");
|
||||||
FROM user_roles ur
|
|
||||||
JOIN roles r ON r.role_id = ur.role_id
|
|
||||||
JOIN role_permissions rp ON rp.role_id = r.role_id
|
|
||||||
JOIN permissions p ON p.permission_id = rp.permission_id
|
|
||||||
WHERE ur.user_id = ?
|
|
||||||
`, [userId]);
|
|
||||||
|
|
||||||
|
// set องค์กรที่ผู้ใช้อยู่ (ใช้สำหรับ ADMIN scope)
|
||||||
|
const orgIds = new Set(
|
||||||
|
rolesRows.filter((r) => r.org_id).map((r) => r.org_id)
|
||||||
|
);
|
||||||
|
const projectIds = new Set(
|
||||||
|
rolesRows.filter((r) => r.project_id).map((r) => r.project_id)
|
||||||
|
);
|
||||||
|
|
||||||
const roleCodes = new Set(rolesRows.map(r => r.role_code));
|
// map สิทธิเป็น: permCode -> { orgIds:Set, projectIds:Set }
|
||||||
const isSuperAdmin = roleCodes.has('SUPER_ADMIN');
|
const perms = new Map();
|
||||||
|
for (const r of permRows) {
|
||||||
|
const key = r.permission_code;
|
||||||
|
if (!perms.has(key))
|
||||||
|
perms.set(key, { orgIds: new Set(), projectIds: new Set() });
|
||||||
|
if (r.org_id) perms.get(key).orgIds.add(r.org_id);
|
||||||
|
if (r.project_id) perms.get(key).projectIds.add(r.project_id);
|
||||||
|
}
|
||||||
|
|
||||||
// set องค์กรที่ผู้ใช้อยู่ (ใช้สำหรับ ADMIN scope)
|
return {
|
||||||
const orgIds = new Set(rolesRows.filter(r => r.org_id).map(r => r.org_id));
|
|
||||||
const projectIds = new Set(rolesRows.filter(r => r.project_id).map(r => r.project_id));
|
|
||||||
|
|
||||||
// map สิทธิเป็น: permCode -> { orgIds:Set, projectIds:Set }
|
|
||||||
const perms = new Map();
|
|
||||||
for (const r of permRows) {
|
|
||||||
const key = r.permission_code;
|
|
||||||
if (!perms.has(key)) perms.set(key, { orgIds: new Set(), projectIds: new Set() });
|
|
||||||
if (r.org_id) perms.get(key).orgIds.add(r.org_id);
|
|
||||||
if (r.project_id) perms.get(key).projectIds.add(r.project_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId,
|
userId,
|
||||||
roleCodes, // Set<role_code>
|
roleCodes, // Set<role_code>
|
||||||
isSuperAdmin, // SUPER_ADMIN = true
|
isSuperAdmin, // SUPER_ADMIN = true
|
||||||
orgIds, // องค์กรของผู้ใช้ (จาก mapping)
|
orgIds, // องค์กรของผู้ใช้ (จาก mapping)
|
||||||
projectIds, // โปรเจกต์ของผู้ใช้ (จาก mapping)
|
projectIds, // โปรเจกต์ของผู้ใช้ (จาก mapping)
|
||||||
perms, // Map<permission_code, {orgIds:Set, projectIds:Set}>
|
perms, // Map<permission_code, {orgIds:Set, projectIds:Set}>
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,30 +73,35 @@ return {
|
|||||||
* - ADMIN: ผ่านได้ "ภายใน org ของตัวเอง" เท่านั้น
|
* - ADMIN: ผ่านได้ "ภายใน org ของตัวเอง" เท่านั้น
|
||||||
* - อื่น ๆ: ต้องถือ permission_code และเข้า scope ที่ถูกต้อง
|
* - อื่น ๆ: ต้องถือ permission_code และเข้า scope ที่ถูกต้อง
|
||||||
*/
|
*/
|
||||||
export function canPerform(principal, permCode, { scope = 'global', orgId = null, projectId = null } = {}) {
|
export function canPerform(
|
||||||
if (!principal) return false;
|
principal,
|
||||||
if (principal.isSuperAdmin) return true;
|
permCode,
|
||||||
const hasAdminRole = principal.roleCodes.has('ADMIN');
|
{ scope = "global", orgId = null, projectId = null } = {}
|
||||||
|
) {
|
||||||
|
if (!principal) return false;
|
||||||
|
if (principal.isSuperAdmin) return true;
|
||||||
|
const hasAdminRole = principal.roleCodes.has("ADMIN");
|
||||||
|
|
||||||
|
if (scope === "global") return !!principal.perms.get(permCode);
|
||||||
|
|
||||||
if (scope === 'global') return !!principal.perms.get(permCode);
|
if (scope === "org") {
|
||||||
|
if (!orgId) return false;
|
||||||
|
if (hasAdminRole && principal.orgIds.has(orgId))
|
||||||
|
return !!principal.perms.get(permCode);
|
||||||
|
const entry = principal.perms.get(permCode);
|
||||||
|
return !!entry && (entry.orgIds.has(orgId) || entry.orgIds.size === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === "project") {
|
||||||
|
if (!projectId) return false;
|
||||||
|
if (hasAdminRole && principal.projectIds.has(projectId))
|
||||||
|
return !!principal.perms.get(permCode);
|
||||||
|
const entry = principal.perms.get(permCode);
|
||||||
|
return (
|
||||||
|
!!entry &&
|
||||||
|
(entry.projectIds.has(projectId) || entry.projectIds.size === 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (scope === 'org') {
|
return false;
|
||||||
if (!orgId) return false;
|
|
||||||
if (hasAdminRole && principal.orgIds.has(orgId)) return !!principal.perms.get(permCode);
|
|
||||||
const entry = principal.perms.get(permCode);
|
|
||||||
return !!entry && (entry.orgIds.has(orgId) || entry.orgIds.size === 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (scope === 'project') {
|
|
||||||
if (!projectId) return false;
|
|
||||||
if (hasAdminRole && principal.projectIds.has(projectId)) return !!principal.perms.get(permCode);
|
|
||||||
const entry = principal.perms.get(permCode);
|
|
||||||
return !!entry && (entry.projectIds.has(projectId) || entry.projectIds.size === 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,14 @@
|
|||||||
|
// FILE: backend/src/utils/scope.js
|
||||||
|
// 03.2 5) เพิ่ม utils/scope.js (ใหม่)
|
||||||
|
// - ใช้ร่วมกับ requirePerm() และ loadPrincipal()
|
||||||
|
// - สำหรับสร้าง SQL WHERE clause ในการ list entities ตาม scope ของผู้ใช้
|
||||||
|
// Scope and permission utilities
|
||||||
|
// - Functions to build SQL WHERE clauses based on user principal and permissions
|
||||||
|
// - Used for filtering list queries according to user's
|
||||||
|
// roles, permissions, and associated orgs/projects
|
||||||
|
// - Works with rbac.js loadPrincipal() output
|
||||||
|
// - Supports SUPER_ADMIN, ADMIN, and scoped permissions
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* สร้าง WHERE fragment + params สำหรับ list ตาม principal
|
* สร้าง WHERE fragment + params สำหรับ list ตาม principal
|
||||||
* - SUPER_ADMIN: ไม่จำกัด
|
* - SUPER_ADMIN: ไม่จำกัด
|
||||||
@@ -12,17 +23,18 @@
|
|||||||
* permCode: permission_code ที่ใช้สำหรับ read list (เช่น 'correspondence.read')
|
* permCode: permission_code ที่ใช้สำหรับ read list (เช่น 'correspondence.read')
|
||||||
* preferProject: true -> บังคับต้อง match project scope ก่อน (ถ้ามี)
|
* preferProject: true -> บังคับต้อง match project scope ก่อน (ถ้ามี)
|
||||||
*/
|
*/
|
||||||
export function buildScopeWhere(principal, {
|
export function buildScopeWhere(
|
||||||
tableAlias, orgColumn, projectColumn, permCode, preferProject = false,
|
principal,
|
||||||
}) {
|
{ tableAlias, orgColumn, projectColumn, permCode, preferProject = false }
|
||||||
if (principal.isSuperAdmin) return { where: '1=1', params: {} };
|
) {
|
||||||
|
if (principal.isSuperAdmin) return { where: "1=1", params: {} };
|
||||||
|
|
||||||
const perm = principal.perms.get(permCode);
|
const perm = principal.perms.get(permCode);
|
||||||
const orgIds = new Set(principal.orgIds);
|
const orgIds = new Set(principal.orgIds);
|
||||||
const projectIds = new Set(principal.projectIds);
|
const projectIds = new Set(principal.projectIds);
|
||||||
|
|
||||||
// กรณี ADMIN: ให้ดูภายใน org/project ตัวเองได้ทั้งหมด (แต่ต้องถือ permCode)
|
// กรณี ADMIN: ให้ดูภายใน org/project ตัวเองได้ทั้งหมด (แต่ต้องถือ permCode)
|
||||||
if (principal.roleCodes.has('ADMIN') && perm) {
|
if (principal.roleCodes.has("ADMIN") && perm) {
|
||||||
const orgList = [...orgIds];
|
const orgList = [...orgIds];
|
||||||
const prjList = [...projectIds];
|
const prjList = [...projectIds];
|
||||||
if (preferProject && prjList.length > 0) {
|
if (preferProject && prjList.length > 0) {
|
||||||
@@ -38,11 +50,11 @@ export function buildScopeWhere(principal, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
// ถ้าไม่มี mapping เลย ให้ไม่เห็นอะไร
|
// ถ้าไม่มี mapping เลย ให้ไม่เห็นอะไร
|
||||||
return { where: '1=0', params: {} };
|
return { where: "1=0", params: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
// บทบาทอื่น: อิงตาม perm scope
|
// บทบาทอื่น: อิงตาม perm scope
|
||||||
if (!perm) return { where: '1=0', params: {} };
|
if (!perm) return { where: "1=0", params: {} };
|
||||||
|
|
||||||
const permOrg = [...perm.orgIds];
|
const permOrg = [...perm.orgIds];
|
||||||
const permPrj = [...perm.projectIds];
|
const permPrj = [...perm.projectIds];
|
||||||
@@ -55,25 +67,31 @@ export function buildScopeWhere(principal, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ถ้า perm ไม่มี scope ผูก (global grant) ให้ผ่านทั้งหมด
|
// ถ้า perm ไม่มี scope ผูก (global grant) ให้ผ่านทั้งหมด
|
||||||
return { where: '1=1', params: {} };
|
return { where: "1=1", params: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* owner resolvers: อ่าน org_id/project_id จาก DB ด้วย id
|
* owner resolvers: อ่าน org_id/project_id จาก DB ด้วย id
|
||||||
* ใช้กับ requirePerm(getOrgId/getProjectId) เสมอ
|
* ใช้กับ requirePerm(getOrgId/getProjectId) เสมอ
|
||||||
*/
|
*/
|
||||||
export function ownerResolvers(sql, mainTable, idColumn = 'id') {
|
export function ownerResolvers(sql, mainTable, idColumn = "id") {
|
||||||
return {
|
return {
|
||||||
async getOrgIdById(req) {
|
async getOrgIdById(req) {
|
||||||
const id = Number(req.params.id ?? req.body?.id);
|
const id = Number(req.params.id ?? req.body?.id);
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
const [[row]] = await sql.query(`SELECT org_id FROM ${mainTable} WHERE ${idColumn}=?`, [id]);
|
const [[row]] = await sql.query(
|
||||||
|
`SELECT org_id FROM ${mainTable} WHERE ${idColumn}=?`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
return row?.org_id ?? null;
|
return row?.org_id ?? null;
|
||||||
},
|
},
|
||||||
async getProjectIdById(req) {
|
async getProjectIdById(req) {
|
||||||
const id = Number(req.params.id ?? req.body?.id);
|
const id = Number(req.params.id ?? req.body?.id);
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
const [[row]] = await sql.query(`SELECT project_id FROM ${mainTable} WHERE ${idColumn}=?`, [id]);
|
const [[row]] = await sql.query(
|
||||||
|
`SELECT project_id FROM ${mainTable} WHERE ${idColumn}=?`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
return row?.project_id ?? null;
|
return row?.project_id ?? null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
15
backend/tests/health.test.js
Normal file
15
backend/tests/health.test.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import app from "../src/index.js"; // สมมติว่าคุณ export app จาก src/index.js
|
||||||
|
import request from "supertest";
|
||||||
|
|
||||||
|
// ปิด server หลังจากเทสเสร็จ
|
||||||
|
afterAll((done) => {
|
||||||
|
app.server.close(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /health", () => {
|
||||||
|
it("should respond with 200 OK and a health message", async () => {
|
||||||
|
const response = await request(app).get("/health");
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.text).toContain("Backend is healthy");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"folders": [
|
|
||||||
{
|
|
||||||
"path": "."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "S:/Documents"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"settings": {}
|
|
||||||
}
|
|
||||||
2
docker-backend-build.yml
Executable file → Normal file
2
docker-backend-build.yml
Executable file → Normal file
@@ -16,7 +16,7 @@ services:
|
|||||||
target: prod
|
target: prod
|
||||||
image: dms-backend:prod
|
image: dms-backend:prod
|
||||||
command: ["true"]
|
command: ["true"]
|
||||||
# docker compose -f docker-backend-build.yml build --no-cache
|
# docker compose -f docker-backend-build.yml build --no-cache 2>&1 | tee backend_build.log
|
||||||
# ***** สำหรับ build บน server เอา ## ออก *****
|
# ***** สำหรับ build บน server เอา ## ออก *****
|
||||||
# สำหรับ build บน local
|
# สำหรับ build บน local
|
||||||
# cd backend
|
# cd backend
|
||||||
|
|||||||
45
docker-compose.yml
Executable file → Normal file
45
docker-compose.yml
Executable file → Normal file
@@ -1,4 +1,4 @@
|
|||||||
# DMS Container v0_6_0
|
# DMS Container v0_7_0
|
||||||
# version: "3.8"
|
# version: "3.8"
|
||||||
x-restart: &restart_policy
|
x-restart: &restart_policy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -57,8 +57,8 @@ services:
|
|||||||
container_name: dms_backend
|
container_name: dms_backend
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
user: "node"
|
#user: "node"
|
||||||
# user: "1000:1000"
|
user: "1000:1000"
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
@@ -81,11 +81,16 @@ services:
|
|||||||
DB_USER: "center"
|
DB_USER: "center"
|
||||||
DB_PASSWORD: "Center#2025"
|
DB_PASSWORD: "Center#2025"
|
||||||
DB_NAME: "dms"
|
DB_NAME: "dms"
|
||||||
JWT_SECRET: "8b0df02e4aee9f9f79a4f2d8ba77b0b82c1ee3446b68cb0bae94ab54d60f8d9e"
|
JWT_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca"
|
||||||
|
JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca"
|
||||||
|
JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c"
|
||||||
|
ACCESS_TTL_MS: "900000"
|
||||||
|
REFRESH_TTL_MS: "604800000"
|
||||||
JWT_EXPIRES_IN: "12h"
|
JWT_EXPIRES_IN: "12h"
|
||||||
PASSWORD_SALT_ROUNDS: "10"
|
PASSWORD_SALT_ROUNDS: "10"
|
||||||
FRONTEND_ORIGIN: "https://lcbp3.mycloudnas.com"
|
FRONTEND_ORIGIN: "https://lcbp3.np-dms.work"
|
||||||
CORS_ORIGINS: "https://backend.np-dms.work,http://localhost:3000,http://127.0.0.1:3000"
|
CORS_ORIGINS: "https://lcbp3.np-dms.work,http://localhost:3000,http://127.0.0.1:3000"
|
||||||
|
COOKIE_DOMAIN: ".np-dms.work"
|
||||||
RATE_LIMIT_WINDOW_MS: "900000"
|
RATE_LIMIT_WINDOW_MS: "900000"
|
||||||
RATE_LIMIT_MAX: "200"
|
RATE_LIMIT_MAX: "200"
|
||||||
BACKEND_LOG_DIR: "/app/logs"
|
BACKEND_LOG_DIR: "/app/logs"
|
||||||
@@ -115,22 +120,27 @@ services:
|
|||||||
container_name: dms_frontend
|
container_name: dms_frontend
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
user: "node"
|
# user: "node"
|
||||||
# user: "1000:1000"
|
user: "1000:1000"
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpus: "1.0"
|
cpus: "2.0"
|
||||||
memory: 1G
|
memory: 2G
|
||||||
environment:
|
environment:
|
||||||
TZ: "Asia/Bangkok"
|
TZ: "Asia/Bangkok"
|
||||||
NODE_ENV: "development"
|
NODE_ENV: "development"
|
||||||
# NEXT_PUBLIC_API_BASE: "/api"
|
# NEXT_PUBLIC_API_BASE: "/api"
|
||||||
CHOKIDAR_USEPOLLING: "1"
|
CHOKIDAR_USEPOLLING: "1"
|
||||||
WATCHPACK_POLLING: "true"
|
WATCHPACK_POLLING: "true"
|
||||||
NEXT_PUBLIC_API_BASE: "https://lcbp3.np-dms.work/api"
|
NEXT_PUBLIC_API_BASE: "https://lcbp3.np-dms.work"
|
||||||
|
NEXT_PUBLIC_AUTH_MODE: "cookie"
|
||||||
|
NEXT_PUBLIC_DEBUG_AUTH: "1"
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
|
INTERNAL_API_BASE: "http://backend:3001"
|
||||||
|
JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca"
|
||||||
|
JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c"
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
networks: [dmsnet]
|
networks: [dmsnet]
|
||||||
@@ -145,7 +155,11 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
'wget -qO- http://127.0.0.1:3000/health | grep -q ''"ok":true''',
|
||||||
|
]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 30
|
retries: 30
|
||||||
@@ -257,12 +271,13 @@ services:
|
|||||||
NODE_ENV: "production"
|
NODE_ENV: "production"
|
||||||
N8N_PATH: "/n8n/"
|
N8N_PATH: "/n8n/"
|
||||||
N8N_PUBLIC_URL: "https://n8n.np-dms.work/"
|
N8N_PUBLIC_URL: "https://n8n.np-dms.work/"
|
||||||
WEBHOOK_URL: "https://ln8n.np-dms.work/"
|
WEBHOOK_URL: "https://n8n.np-dms.work/"
|
||||||
N8N_EDITOR_BASE_URL: "https://n8n.np-dms.work/"
|
N8N_EDITOR_BASE_URL: "https://n8n.np-dms.work/"
|
||||||
N8N_PROTOCOL: "https"
|
N8N_PROTOCOL: "https"
|
||||||
N8N_HOST: "n8n.np-dms.work"
|
N8N_HOST: "n8n.np-dms.work"
|
||||||
N8N_PORT: "5678"
|
N8N_PORT: "5678"
|
||||||
N8N_PROXY_HOPS: "1"
|
N8N_PROXY_HOPS: "1"
|
||||||
|
N8N_DIAGNOSTICS_ENABLED: "false"
|
||||||
N8N_SECURE_COOKIE: "true"
|
N8N_SECURE_COOKIE: "true"
|
||||||
N8N_ENCRYPTION_KEY: "9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI"
|
N8N_ENCRYPTION_KEY: "9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI"
|
||||||
N8N_BASIC_AUTH_ACTIVE: "true"
|
N8N_BASIC_AUTH_ACTIVE: "true"
|
||||||
@@ -320,8 +335,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
frontend:
|
# frontend:
|
||||||
condition: service_healthy
|
# condition: service_healthy
|
||||||
phpmyadmin:
|
phpmyadmin:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
n8n:
|
n8n:
|
||||||
|
|||||||
26
docker-frontend-build.yml
Executable file → Normal file
26
docker-frontend-build.yml
Executable file → Normal file
@@ -1,31 +1,35 @@
|
|||||||
services:
|
services:
|
||||||
frontend_dev_image:
|
frontend_dev_image:
|
||||||
build:
|
build:
|
||||||
# context: /share/Container/dms/frontend
|
context: /share/Container/dms/frontend
|
||||||
context: ./frontend
|
# context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: dev
|
target: dev
|
||||||
|
args:
|
||||||
|
- NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
|
||||||
|
- NODE_ENV=development
|
||||||
image: dms-frontend:dev
|
image: dms-frontend:dev
|
||||||
command: ["true"]
|
command: ["true"]
|
||||||
|
|
||||||
frontend_prod_image:
|
frontend_prod_image:
|
||||||
build:
|
build:
|
||||||
## context: /share/Container/dms/frontend
|
context: /share/Container/dms/frontend
|
||||||
context: ./frontend
|
# context: ./frontend
|
||||||
args:
|
args:
|
||||||
- NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
|
- NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
|
||||||
- NODE_ENV=production #added
|
- NODE_ENV=production
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: prod
|
target: prod
|
||||||
## environment:
|
|
||||||
## - NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
|
|
||||||
image: dms-frontend:prod
|
image: dms-frontend:prod
|
||||||
command: ["true"]
|
command: ["true"]
|
||||||
environment:
|
|
||||||
- NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
|
|
||||||
- NODE_ENV=production
|
|
||||||
# docker compose -f docker-frontend-build.yml build --no-cache
|
# docker compose -f docker-frontend-build.yml build --no-cache
|
||||||
# **** สำหรับ build บน server เอา ## ออก *****
|
# docker compose -f docker-frontend-build.yml build --no-cache 2>&1 | tee frontend_build.log
|
||||||
|
|
||||||
|
# สร้าง package-lock.json
|
||||||
|
# cd frontend
|
||||||
|
# docker run --rm -v "$PWD:/app" -w /app node:24-alpine npm install
|
||||||
|
|
||||||
# สำหรับ build บน local
|
# สำหรับ build บน local
|
||||||
# cd frontend
|
# cd frontend
|
||||||
# docker build -t dms-frontend:dev --target dev .
|
# docker build -t dms-frontend:dev --target dev .
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
.next
|
||||||
.next/cache
|
.next/cache
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
.DS_Store
|
.DS_Store
|
||||||
logs
|
.env*.local
|
||||||
|
*.logs
|
||||||
9
frontend/.editorconfig
Executable file
9
frontend/.editorconfig
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
15
frontend/.eslintrc.json
Executable file
15
frontend/.eslintrc.json
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
// .eslintrc.json
|
||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"extends": ["next/core-web-vitals"],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2023,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/no-unescaped-entities": "off",
|
||||||
|
"@next/next/no-img-element": "off",
|
||||||
|
"no-console": ["warn", { "allow": ["warn", "error"] }]
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["node_modules/", ".next/", "dist/", "coverage/"]
|
||||||
|
}
|
||||||
11
frontend/.prettierrc.json
Executable file
11
frontend/.prettierrc.json
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
// File: .prettierrc.json
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user