59 Commits

Author SHA1 Message Date
fb26bb7b25 chore(git): ignore n8n-postgres/ and stop tracking it 2025-10-11 11:58:53 +07:00
c55f464f3c feat: Gen package-lock... 2025-10-11 10:30:47 +07:00
aa799edf2c chore(git): stop tracking top-level npm/ (NPM data) and ignore it 2025-10-11 10:13:53 +07:00
cc47c6f5f1 chore(git): stop tracking top-level npm/ (NPM data) and ignore it 2025-10-11 10:09:54 +07:00
admin
15145260f9 feat: backend rebuild 2025-10-11 09:24:40 +07:00
360ab1ac12 251011 start 2025-10-11 08:10:49 +07:00
admin
e58e164e54 update README.md 2025-10-10 16:42:32 +07:00
bbfbc5b910 fix: tailwind v4 postcss, auth-server session, eslint cleanups 2025-10-09 15:47:56 +07:00
670228b76e xxx 2025-10-05 11:57:43 +07:00
admin
754e494e7f fronted แก้ layout build 2 2025-10-05 11:05:03 +07:00
admin
5dec188744 fronted แก้ layout build dev&proc 2025-10-05 10:57:54 +07:00
admin
02e509986b fronted build dev&proc 2025-10-05 10:18:59 +07:00
admin
da568bb85f fronted build js-cookiep 2025-10-05 09:40:55 +07:00
admin
3448594bc5 Apply .gitignore cleanup 2025-10-05 09:21:04 +07:00
admin
d2a7a3e478 feat(dashboard): backend Sequelize.js 2025-10-04 17:26:36 +07:00
admin
72c2573648 feat(dashboard): backend rbac_admin.js 2025-10-04 17:11:30 +07:00
admin
c98baa94fc feat(dashboard): backend user.js 2025-10-04 17:08:58 +07:00
admin
c414899a4f feat(dashboard): backend and frontend 2025-10-04 16:46:39 +07:00
admin
1ef1f8148f feat(dashboard): backend/src/index.js 2025-10-04 16:17:21 +07:00
admin
772239e708 feat(dashboard): เพมสวนจดการ user 2025-10-04 16:07:22 +07:00
admin
7f41c35cb8 stable: auth+dashboard OK 2025-10-04 14:56:44 +07:00
admin
d3844aec71 251004 backend restore /routes/auth.js 2025-10-04 11:24:01 +07:00
admin
33022c1840 251004 frontend backend 2 2025-10-04 10:58:25 +07:00
admin
a70ad11035 251004 frontend backend 2025-10-04 10:56:56 +07:00
admin
10150583cc 251003 frontend 2 2025-10-03 16:52:13 +07:00
admin
4d7e69247d 251003 frontend NPM 2025-10-03 16:14:09 +07:00
eeb7808e29 251003 gitignore 2025-10-03 08:21:28 +07:00
admin
03a8a3b864 2nd File: frontend/app/(auth)/login/page.jsx 2025-10-02 09:49:50 +07:00
admin
6fea909902 File: frontend/app/(auth)/login/page.jsx 2025-10-02 09:04:00 +07:00
admin
dd48a26196 251002 frontend/app/(auth)/login/page.jsx 2025-10-02 08:54:52 +07:00
admin
cb4146fa35 251002 frontend/app/(protected)/layout.jsx 2025-10-02 08:30:45 +07:00
admin
60880fb12e gitignore 2025-10-02 08:11:20 +07:00
d3339d75bf layout errer 2025-10-01 17:15:51 +07:00
admin
a1e9600ad5 ปรับ frontend/app/(protected)/layout.jsx 2025-10-01 15:44:57 +07:00
admin
a3d2e24861 Merge branch 'main' of ssh://git.np-dms.work:2222/np-dms/lcbp3.np-dms.work 2025-10-01 15:33:11 +07:00
admin
2215633fb9 ปรับ frontend 2025-10-01 13:53:46 +07:00
bf3d9fc1d0 frontend add _auth 2025-10-01 13:50:35 +07:00
admin
5cac3bdabf ปรับ git ignoer 2025-10-01 11:22:14 +07:00
admin
905afb56f5 05.1 ปรบปรง backend ทงหมด และ frontend/login 2025-10-01 11:14:11 +07:00
5be0f5407b backen: jwt 2025-09-30 16:38:37 +07:00
admin
8b2dff8d1d mariadb rebuild 2025-09-30 14:53:22 +07:00
83fc120885 build frontend ใหม่ ผ่านทั้ง dev และ proc 2025-09-30 14:04:48 +07:00
admin
60ea49ac4f Update frontend new build dev, proc 7 2025-09-29 17:25:55 +07:00
admin
1c710015de Update frontend new build dev, proc 6 2025-09-29 17:19:52 +07:00
admin
8d89e5f49a Update frontend new build dev, proc 5 2025-09-29 17:11:42 +07:00
admin
cac84677fb Update frontend new build dev, proc 4 2025-09-29 17:06:14 +07:00
admin
709d18199c Update frontend new build dev, proc 3 2025-09-29 16:39:13 +07:00
admin
b7260357af Update frontend new build dev, proc 2 2025-09-29 16:35:43 +07:00
admin
b686855d82 Update frontend new build dev, proc 2 2025-09-29 16:33:41 +07:00
admin
a337732d47 Update frontend new build dev, proc 2025-09-29 16:30:21 +07:00
admin
7dd5ce8015 Update frontend login page.jsx และ backend 2025-09-29 13:25:09 +07:00
admin
aca3667a9d Update login page.jsx ครั้งที่ 2 2025-09-29 08:39:12 +07:00
admin
d8c604de1d Update login page.jsx 2025-09-29 08:31:48 +07:00
admin
5ce2b68155 feat: แก้ไขส่วน <html> 2025-09-27 16:57:44 +07:00
admin
82fc98e9df feat: แกไข frontend/lib/auth.js 2025-09-27 16:46:47 +07:00
admin
83a8cddc82 feat: แกไขสวน pages.jsx, layout.jsx 2025-09-27 16:26:22 +07:00
admin
db7030883f feat: แกไขสวน backend ใหเขากบ frontend 2025-09-27 11:30:31 +07:00
admin
4cb7801fe8 feat: แก้ frontend logi npages 2025-09-26 16:51:36 +07:00
admin
9a9d0955a2 update: vscode config 2025-09-26 08:14:16 +07:00
2489 changed files with 64665 additions and 1508480 deletions

0
.github/copilot-instructions.md vendored Executable file → Normal file
View File

20
.gitignore vendored Executable file → Normal file
View File

@@ -1,7 +1,16 @@
# ยกเว้นโฟลเดอร์
.devcontainer/
.qsync/
@Recently-Snapshot/
Documents/
mariadb/data/
n8n*/
n8n-postgres/
npm/
phpmyadmin/
pgadmin/
.tmp.driveupload
.qsync
# =====================================================
# IDE/Editor settings
# =====================================================
@@ -14,9 +23,9 @@ Documents/
/frontend/node_modules/
**/node_modules/
# lockfiles
/backend/package-lock.json
/frontend/package-lock.json
**/package-lock.json
# /backend/package-lock.json
# /frontend/package-lock.json
# **/package-lock.json
# =====================================================
# Next.js build output
# =====================================================
@@ -84,4 +93,9 @@ docker-compose.override.*.yml
/backend/.cache/
/frontend/.cache/
.tmp/
.tmp*.*/
.cache/
# Ignore Nginx Proxy Manager data
/npm/
/n8n-postgres/

View File

@@ -1,4 +1,4 @@
[/dms]
max_log = 361676
number = 5
max_log = 510381
number = 3
finish = 1

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

506
Architech.md Executable file
View File

@@ -0,0 +1,506 @@
# DMS Architecture Deep Dive (Backend + Frontend)
**Project:** Document Management System (DMS) — LCBP3
**Platform:** QNAP TS473A (Container Station)
**Last updated:** 20251007 (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/dmsdata** พร้อมมาตรฐานการตั้งชื่อ+โฟลเดอร์
* Dev/Prod แยกชัดเจนผ่าน Docker multistage + dockercompose + โฟลเดอร์ 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 multisubdomain) │
│ └─ 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; ใช้ clientguard + 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
* Seablue palette, sidebar พับได้, cardbased KPI
* ตารางข้อมูลเตรียมรองรับ **serverside 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): webvitals, 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` (perroute) → 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/dmsdata**
* โครงโฟลเดอร์: `{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 (dmsdata)
### 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/containerside)
---
## 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, autoaudit 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)`)
* Fulltext 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
* เก็บสำเนา offNAS (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 หลาย subdomain ใน UI แต่ mainten ดีเรื่อง symlink/renew
### 5.3 TLS & Certificates
* Lets Encrypt (HTTP01 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 nonroot (`user: node` สำหรับ FE/BE)
* จำกัด capabilities; readonly 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**: keepalive, gzip/deflate at proxy, pool 1025, 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 offNAS)
---
## 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. **Serverside 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 multistage; 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**: UTF8 (`utf8mb4_unicode_ci`)
* **Large file policy**: size limit (e.g., 50200MB), 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
View File

@@ -1,29 +1,695 @@
# บทบาท: คุณคือ 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
# 📝 0. Project Title: Document Management System (DMS) Web Application for Laem Chabang Port Development Project, Phase 3
## 0. Project
### 📌 0.1 Project Overview / Description
- ระบบ Document Management System (DMS) เป็นเว็บแอปพลิเคชันที่ออกแบบมาเพื่อจัดการเอกสารภายในโครงการอย่างมีประสิทธิภาพ
- โดยมีฟังก์ชันหลักในการอัปโหลด จัดเก็บ ค้นหา แชร์ และควบคุมสิทธิ์การเข้าถึงเอกสาร
- ระบบนี้จะช่วยลดการใช้เอกสารกระดาษ เพิ่มความปลอดภัยในการจัดเก็บข้อมูล
- เพิ่มความสะดวกในการทำงานร่วมกันระหว่างองกรณ์
### 🎯 0.2 Objectives
- พัฒนาระบบที่สามารถจัดการเอกสารได้อย่างเป็นระบบ
- ลดความซ้ำซ้อนในการจัดเก็บเอกสาร
- เพิ่มความปลอดภัยในการเข้าถึงและจัดการเอกสาร
- รองรับการทำงานร่วมกันแบบออนไลน์
### 📦 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/dmsdata พร้อมมาตรฐานการตั้งชื่อ+โฟลเดอร์
- Dev/Prod แยกชัดเจนผ่าน Docker multistage + dockercompose + โฟลเดอร์ 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 multisubdomain) │
│ └─ 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; ใช้ clientguard + 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
- Seablue palette, sidebar พับได้, cardbased KPI
- ตารางข้อมูลเตรียมรองรับ serverside 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): webvitals, 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 (perroute) → 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/dmsdata
- โครงโฟลเดอร์: {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 (dmsdata)
#### 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/containerside)
### 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, autoaudit 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))
- Fulltext 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
- เก็บสำเนา offNAS (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 หลาย subdomain ใน UI แต่ mainten ดีเรื่อง symlink/renew
#### 1.6.3 TLS & Certificates
- Lets Encrypt (HTTP01 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 nonroot (user: node สำหรับ FE/BE)
- จำกัด capabilities; readonly 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: keepalive, gzip/deflate at proxy, pool 1025, 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 offNAS)
### 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. Serverside 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 multistage; 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: UTF8 (utf8mb4_unicode_ci)
- Large file policy: size limit (e.g., 50200MB), 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/
├─ docker-compose.yml # Create โดย UI Container Station
├─ mariadb/
@@ -75,31 +741,37 @@
├─ n8n-cache/
├─ n8n-postgres/
└─ logs/
├─ backend/
├─ frontend/
├─ nginx/
├─ pgadmin/
├─ phpmyadmin/
└─ postgres_n8n/
├─ backend/
├─ frontend/
├─ nginx/
├─ pgadmin/
├─ phpmyadmin/
└─ postgres_n8n/
/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 ทั้งหมด
- การทดสอบระบบ backend ทุกส่วน ให้พร้อม สำหรับ frontend
- ไม่ใช้ .env เด็ดขาด Container Station ไม่รองรับ และ docker-compose.yml ได้ทดสอบ รันบน Container station มาแล้ว
- 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
View File

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

View 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 .

Binary file not shown.

0
backend/ed25519 → backend/.backup/ed25519 Executable file → Normal file
View File

0
backend/ed25519.pub → backend/.backup/ed25519.pub Executable file → Normal file
View File

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

View File

@@ -1,8 +1,11 @@
.git
.vscode
.backup
node_modules
logs
*.log
Dockerfile*
Dockerfile*.*
*.yml
README*.md
coverage
tmp

15
backend/.eslintrc.json Normal file
View 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
View File

@@ -0,0 +1,3 @@
# Ignore Nginx Proxy Manager data
/npm/

7
backend/.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"arrowParens": "always",
"printWidth": 80
}

View File

@@ -67,3 +67,4 @@ EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --retries=10 \
CMD wget -qO- http://127.0.0.1:3001/health || exit 1
CMD ["node","src/index.js"]

159
backend/README2.md Normal file
View 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
View 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

6041
backend/package-lock.json generated Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,25 @@
{
"name": "dms-backend",
"version": "0.6.0",
"version": "0.8.0",
"private": true,
"type": "module",
"main": "src/index.js",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"dev": "nodemon --watch src src/index.js",
"dev:desktop": "node --watch 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)})\"",
"postinstall": "node -e \"console.log('Installed dms-backend %s','0.6.0')\""
},
"dependencies": {
"bcrypt": "5.1.1",
"bcryptjs": "^2.4.3",
@@ -35,6 +38,12 @@
"winston": "^3.13.0"
},
"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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -1,24 +1,45 @@
export const config = {
PORT: Number(process.env.BACKEND_PORT || 3001),
DB: {
HOST: process.env.DB_HOST || 'mariadb',
PORT: Number(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 || '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),
// FILE: backend/src/config.js
// Centralized configuration (ESM)
const toInt = (v, d) => {
const n = Number(v);
return Number.isFinite(n) ? n : d;
};
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;

View File

@@ -1,45 +1,60 @@
// =============================================================
// FILE: src/config/permissions.js
// Purpose: Map permission_code to your seed naming convention.
// - Choose one PROFILE (by SEED_PROFILE env) or edit inline to match exactly
// what's in 06_dms_v0_5_0_data_v5_1_seed_users.sql
// =============================================================
// Reference enum สำหรับฝั่งโค้ด/ฟรอนต์เท่านั้น
// แหล่งจริงของ scope อ่านจาก DB: permissions.scope_level (ผ่าน requirePerm())
const V5_DOT = {
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 PERM = {
organizations: {
view: "organizations.view",
manage: "organizations.manage",
},
projects: {
view: "projects.view",
manage: "projects.manage",
partiesManage: "project_parties.manage",
},
drawings: {
view: "drawings.view",
upload: "drawings.upload",
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",
},
};
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 { PERM };
export default PERM;

View 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 เรียกอยู่

View File

@@ -1,13 +1,13 @@
// ESM
import mysql from 'mysql2/promise';
// 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',
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({
@@ -17,21 +17,23 @@ const pool = mysql.createPool({
password: DB_PASSWORD,
database: DB_NAME,
connectionLimit: Number(DB_CONN_LIMIT),
waitForConnections: true, // Recommended for handling connection spikes
waitForConnections: true,
namedPlaceholders: true,
dateStrings: true, // Keep dates as strings
timezone: 'Z', // Store and retrieve dates in UTC
dateStrings: true, // คงวันที่เป็น string
timezone: "Z", // ใช้ UTC
});
/**
* Executes a SQL query with parameters.
* @param {string} sql The SQL query string.
* @param {object} [params={}] The parameters to bind to the query.
* @returns {Promise<any[]>} A promise that resolves to an array of rows.
* เรียก Stored Procedure แบบง่าย
* @param {string} procName ชื่อโปรซีเยอร์ เช่น "sp_rfa_create_with_items"
* @param {Array<any>} params ลำดับพารามิเตอร์
* @returns {Promise<any>} rows จาก CALL
*/
export async function query(sql, params = {}) {
const [rows] = await pool.execute(sql, params);
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;
export default pool; // ใช้ sql.query(...) ได้ตามที่ routes เรียกอยู่

View 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
View File

@@ -1,5 +1,9 @@
import { Sequelize } from 'sequelize';
import { config } from '../config.js';
// 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,
@@ -8,36 +12,60 @@ export const sequelize = new Sequelize(
{
host: config.DB.HOST,
port: config.DB.PORT,
dialect: 'mariadb',
dialect: "mariadb",
logging: false,
dialectOptions: { timezone: 'Z' },
define: {
freezeTableName: true,
underscored: false,
timestamps: false,
},
dialectOptions: { timezone: "Z" },
define: { freezeTableName: true, underscored: false, timestamps: false },
pool: { max: 10, min: 0, idle: 10000 },
}
);
import UserModel from './models/User.js';
import RoleModel from './models/Role.js';
import PermissionModel from './models/Permission.js';
import UserRoleModel from './models/UserRole.js';
import RolePermissionModel from './models/RolePermission.js';
export let User = null;
export let Role = null;
export let Permission = null;
export let UserRole = null;
export let RolePermission = null;
export const User = UserModel(sequelize);
export const Role = RoleModel(sequelize);
export const Permission = PermissionModel(sequelize);
export const UserRole = UserRoleModel(sequelize);
export const RolePermission = RolePermissionModel(sequelize);
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);
User.belongsToMany(Role, { through: UserRole, foreignKey: 'user_id', otherKey: 'role_id' });
Role.belongsToMany(User, { through: UserRole, foreignKey: 'role_id', otherKey: 'user_id' });
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);
Role.belongsToMany(Permission, { through: RolePermission, foreignKey: 'role_id', otherKey: 'permission_id' });
Permission.belongsToMany(Role, { through: RolePermission, foreignKey: 'permission_id', otherKey: 'role_id' });
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();
}

View File

@@ -1,200 +1,173 @@
// src/index.js (ESM)
// -------------------
// Node >= 18, Express 4/5 compatible
// FILE: backend/src/index.js (ESM) ไฟล์ฉบับ “Bearer-only”
// FILE: src/index.js (ESM)
import fs from "node:fs";
import express from "express";
import cors from "cors";
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';
import { loadPrincipalMw } from './middleware/loadPrincipal.js';
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
import authRoutes from './routes/auth.js';
import lookupRoutes from './routes/lookup.js';
import organizationsRoutes from './routes/organizations.js';
import projectsRoutes from './routes/projects.js';
import correspondencesRoutes from './routes/correspondences.js';
import rfasRoutes from './routes/rfas.js';
import drawingsRoutes from './routes/drawings.js';
import transmittalsRoutes from './routes/transmittals.js';
import contractsRoutes from './routes/contracts.js';
import contractDwgRoutes from './routes/contract_dwg.js';
import categoriesRoutes from './routes/categories.js';
import volumesRoutes from './routes/volumes.js';
import uploadsRoutes from './routes/uploads.js';
import usersRoutes from './routes/users.js';
import permissionsRoutes from './routes/permissions.js';
import authRoutes from "./routes/auth.js";
import lookupRoutes from "./routes/lookup.js";
import organizationsRoutes from "./routes/organizations.js";
import projectsRoutes from "./routes/projects.js";
import correspondencesRoutes from "./routes/correspondences.js";
import rfasRoutes from "./routes/rfas.js";
import drawingsRoutes from "./routes/drawings.js";
import transmittalsRoutes from "./routes/transmittals.js";
import contractsRoutes from "./routes/contracts.js";
import contractDwgRoutes from "./routes/contract_dwg.js";
import categoriesRoutes from "./routes/categories.js";
import volumesRoutes from "./routes/volumes.js";
import uploadsRoutes from "./routes/uploads.js";
import usersRoutes from "./routes/users.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 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 = [
'http://localhost:3000',
'http://127.0.0.1:3000',
"http://localhost:3000",
"http://127.0.0.1:3000",
FRONTEND_ORIGIN,
...(process.env.CORS_ALLOWLIST
? process.env.CORS_ALLOWLIST.split(",").map((x) => x.trim()).filter(Boolean)
: []),
].filter(Boolean);
// ที่เก็บ log ภายใน container ถูก bind ไปที่ /share/Container/dms/logs/backend
const LOG_DIR = process.env.BACKEND_LOG_DIR || '/app/logs';
// สร้างโฟลเดอร์ log ถ้ายังไม่มี (แก้ปัญหา Permission denied ล่วงหน้า: ให้ host map เป็น 775 และ uid=100)
const LOG_DIR = process.env.BACKEND_LOG_DIR || "/app/logs";
try {
if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
} 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();
app.set("trust proxy", 1);
// CORS แบบกำหนด origin ตามรายการที่อนุญาต + อนุญาต credentials
app.use(cors({
// CORS: allow list
app.use(
cors({
origin(origin, cb) {
// อนุญาต server-to-server / curl ที่ไม่มี Origin
if (!origin) return cb(null, true);
return cb(null, ALLOW_ORIGINS.includes(origin));
if (!origin) return cb(null, true); // server-to-server / curl
cb(null, ALLOW_ORIGINS.includes(origin));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
exposedHeaders: ['Content-Disposition', 'Content-Length'],
}));
// จัดการ preflight ให้ครบ
app.options('*', cors({
credentials: false, // Bearer-only
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Authorization",
"X-Requested-With",
"Accept",
"Origin",
"Referer",
"User-Agent",
"Cache-Control",
"Pragma",
],
exposedHeaders: ["Content-Disposition", "Content-Length"],
})
);
app.options(
"*",
cors({
origin(origin, cb) {
if (!origin) return cb(null, true);
return cb(null, ALLOW_ORIGINS.includes(origin));
cb(null, ALLOW_ORIGINS.includes(origin));
},
credentials: true,
}));
credentials: false,
})
);
app.use(cookieParser());
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
// Payload limits
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Access log (ขั้นต่ำ): พิมพ์ลง stdout ให้ Docker เก็บ; ถ้าต้องการเขียนไฟล์ ให้เปลี่ยนเป็น fs.appendFileSync
// minimal access log
app.use((req, _res, next) => {
console.log(`[REQ] ${req.method} ${req.originalUrl}`);
next();
});
/* ==========================
* HEALTH / READY / INFO
* ========================== */
app.get('/health', async (req, res) => {
// health/info (เปิดทั้ง /health, /livez, /readyz, /info)
app.get("/health", async (_req, res) => {
try {
const [[{ now }]] = await sql.query('SELECT NOW() AS now');
return res.json({ status: 'ok', db: 'ok', now });
const [[{ now }]] = await sql.query("SELECT NOW() AS now");
res.json({ status: "ok", db: "ok", now });
} 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 });
}
});
// Kubernetes-style endpoints (ถ้าใช้)
app.get('/livez', (req, res) => res.send('ok'));
app.get('/readyz', async (req, res) => {
try {
await sql.query('SELECT 1');
res.send('ready');
} catch {
res.status(500).send('not-ready');
}
app.get("/livez", (_req, res) => res.send("ok"));
app.get("/readyz", async (_req, res) => {
try { await sql.query("SELECT 1"); res.send("ready"); }
catch { res.status(500).send("not-ready"); }
});
// เวอร์ชัน/บิลด์ (เติมจาก ENV ถ้าต้องการ)
app.get('/info', (req, res) => {
app.get("/info", (_req, res) =>
res.json({
name: 'dms-backend',
name: "dms-backend",
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,
});
});
})
);
/* ==========================
* PROTECTED API
* ========================== */
// ต้อง auth + principal ก่อนเข้าทุก /api/*
app.use('/api', healthRouter);
app.use('/api/auth', authRoutes); // login/refresh/logout (ไม่ต้องผ่าน authJwt ทั้งกลุ่ม)
app.use('/api', authJwt(), loadPrincipalMw()); // จากนี้ต้องมี JWT + principal
// ---------- Public (no auth) ----------
app.use("/api", healthRouter);
app.use("/api/auth", authRoutes);
app.use('/api/lookup', lookupRoutes);
// โมดูลหลัก
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);
// ---------- Protected (Bearer + Principal) ----------
app.use("/api", authJwt(), loadPrincipalMw());
/* ==========================
* NOT FOUND & ERROR HANDLERS
* ========================== */
app.use((req, res) => {
res.status(404).json({ error: 'NOT_FOUND', path: req.originalUrl });
});
app.use("/api/lookup", lookupRoutes);
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);
// ต้องมี 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
app.use((err, req, res, _next) => {
console.error('[UNHANDLED ERROR]', err);
const status = err?.status || 500;
res.status(status).json({
error: 'SERVER_ERROR',
message: NODE_ENV === 'production' ? undefined : err?.message,
});
app.use((err, _req, res, _next) => {
console.error("[UNHANDLED ERROR]", err);
res.status(err?.status || 500).json({ error: "SERVER_ERROR" });
});
/* ==========================
* START SERVER
* ========================== */
// START
const server = app.listen(PORT, () => {
console.log(`Backend API listening on ${PORT} (env=${NODE_ENV})`);
});
/* ==========================
* GRACEFUL SHUTDOWN
* ========================== */
// Shutdown
async function shutdown(signal) {
try {
console.log(`[SHUTDOWN] ${signal} received`);
await new Promise(resolve => server.close(resolve));
await new Promise((resolve) => server.close(resolve));
try { await sql.end(); } catch {}
console.log('[SHUTDOWN] complete');
console.log("[SHUTDOWN] complete");
process.exit(0);
} catch (e) {
console.error('[SHUTDOWN] error', e);
console.error("[SHUTDOWN] error", e);
process.exit(1);
}
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
export default app;

View File

@@ -1,103 +1,43 @@
import { sequelize } from '../db/sequelize.js';
import UPRModel from '../db/models/UserProjectRole.js';
// FILE: src/middleware/abac.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
/**
* ดึง project_id ที่ผู้ใช้เข้าถึงได้ (จาก user_project_roles)
*/
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) {
export function projectScopedViewFallback(moduleName) {
// ใช้ในเคส legacy เท่านั้น
return async (req, res, next) => {
const roles = req.user?.roles || [];
const isAdmin = roles.includes('Admin');
const permName = `${moduleName}:view`;
const hasViewPerm = (req.user?.permissions || []).includes(permName);
const p = req.principal;
if (!p) return res.status(401).json({ error: "Unauthenticated" });
// Admin ผ่านได้เสมอ
if (isAdmin) return next();
const hasViewPerm = p.can?.(`${moduleName}.view`) || p.permissions?.has?.(`${moduleName}.view`);
if (p.is_superadmin) return next();
const qProjectId = req.query?.project_id ? Number(req.query.project_id) : null;
const memberProjects = await getUserProjectIds(req.user?.user_id);
if (qProjectId) {
// ต้องเป็นสมาชิกโปรเจ็กต์นั้น หรือมี perm view
if (hasViewPerm || memberProjects.includes(qProjectId)) return next();
return res.status(403).json({ error: 'Forbidden: not a member of project' });
if (hasViewPerm || p.inProject(qProjectId)) return next();
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
} else {
// ไม่มี project_id: ถ้ามี perm view → อนุญาตทั้งหมด
// ถ้าไม่มี perm view → จำกัดด้วยรายการโปรเจ็กต์ที่เป็นสมาชิก (บันทึกไว้ใน req.abac.filterProjectIds)
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.filterProjectIds = memberProjects;
req.abac.filterProjectIds = p.project_ids;
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() {
return async (req, res, next) => {
const roles = req.user?.roles || [];
const isAdmin = roles.includes('Admin');
if (isAdmin) return next();
return (req, res, next) => {
const p = req.principal;
if (!p) return res.status(401).json({ error: "Unauthenticated" });
if (p.is_superadmin) return next();
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();
};
}

View 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
View File

@@ -1,37 +1,30 @@
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
import { User, Role, UserRole } from '../db/sequelize.js';
// FILE: backend/src/middleware/auth.js
// (ถ้ายังใช้อยู่) ปรับให้สอดคล้อง Bearer + principal
import jwt from "jsonwebtoken";
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) {
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) {
if (req.path === '/health') return next(); // อนุญาต health เสมอ
const hdr = req.headers.authorization || '';
const token = hdr.startsWith('Bearer ') ? hdr.slice(7) : null;
if (!token) return res.status(401).json({ error: 'Missing token' });
const h = req.headers.authorization || "";
const m = /^Bearer\s+(.+)$/i.exec(h || "");
if (!m) return res.status(401).json({ error: "Missing token" });
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();
} 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);
}

View File

@@ -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 ตามที่ใช้จริง)
import jwt from 'jsonwebtoken';
const { JWT_SECRET = 'dev-secret' } = process.env;
// - ตรวจ token และเติม req.user
// - ไม่ได้เติม roles/permissions (ถ้าต้องการให้ใช้ auth.js แทนหรือร่วมกัน)
import jwt from "jsonwebtoken";
export function authJwt() {
const { JWT_SECRET = "dev-secret" } = process.env;
return (req, res, next) => {
const h = req.headers.authorization || '';
const token = h.startsWith('Bearer ') ? h.slice(7) : null;
if (!token) return res.status(401).json({ error: 'Unauthenticated' });
const h = req.headers.authorization || "";
// const token = h.startsWith("Bearer ") ? h.slice(7) : null;
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 {
const payload = jwt.verify(token, JWT_SECRET);
req.user = { user_id: payload.user_id, username: payload.username };
//const payload = jwt.verify(token, JWT_SECRET);
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();
} catch (e) {
return res.status(401).json({ error: 'Invalid token' });
return res.status(401).json({ error: "Unauthenticated" });
}
};
}

View File

@@ -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) {
res.status(404).json({ error: 'Not Found' });
res.status(404).json({ error: "Not Found" });
}
export function errorHandler(err, _req, res, _next) {
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
View 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
View File

@@ -1,15 +1,98 @@
// loadPrincipal.js
import { loadPrincipal } from '../utils/rbac.js';
// FILE: src/middleware/loadPrincipal.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() {
return async (req, res, next) => {
try {
if (!req.user?.user_id) return res.status(401).json({ error: 'Unauthenticated' });
req.principal = await loadPrincipal(req.user.user_id);
const uid = req?.auth?.user_id || 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();
} catch (err) {
console.error('loadPrincipal error', err);
res.status(500).json({ error: 'Failed to load principal' });
console.error("loadPrincipal error", err);
res.status(500).json({ error: "Failed to load principal" });
}
};
}

View File

@@ -1,12 +1,15 @@
/**
* requirePerm('rfa:create') => ตรวจว่ามี permission นี้ใน req.user.permissions
* ต้องแน่ใจว่าเรียก enrichPermissions() มาก่อน หรือคำนวณที่จุดเข้าใช้งาน
*/
// FILE: src/middleware/permGuard.js
// Permission guard middleware
// - 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) {
return (req, res, next) => {
const perms = req.user?.permissions || [];
const ok = perms.some(p => allowedPerms.includes(p));
if (!ok) return res.status(403).json({ error: 'Forbidden' });
const p = req.principal;
if (!p) return res.status(401).json({ error: "Unauthenticated" });
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();
};
}

View File

@@ -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) {
// ดึง roles ของผู้ใช้
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 [];
// ดึง permission ผ่าน role_permissions
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 [];
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() {
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 {
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;
} catch (e) {
req.user.permissions = [];
} catch {
if (req.principal) req.principal.permissions = new Set();
if (req.user) req.user.permissions = [];
}
next();
};

View File

@@ -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) {
return (req, res, next) => {
const roles = req.user?.roles || [];
const ok = roles.some(r => allowed.includes(r));
if (!ok) return res.status(403).json({ error: 'Forbidden' });
const roles = (req.principal?.roles || []).map(r => r.role_code);
const ok = roles.some((r) => allowed.includes(r)) || req.principal?.is_superadmin;
if (!ok) return res.status(403).json({ error: "FORBIDDEN_ROLE", need_any_of: allowed });
next();
};
}
export function requirePermission(...allowedPerms) {
export function requirePermissionCode(...codes) {
return (req, res, next) => {
const perms = req.user?.permissions || [];
const ok = perms.some(p => allowedPerms.includes(p));
if (!ok) return res.status(403).json({ error: 'Forbidden' });
const p = req.principal;
if (!p) return res.status(401).json({ error: "Unauthenticated" });
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();
};
}

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

View File

@@ -1,25 +1,64 @@
// src/middleware/requirePerm.js
import { canPerform } from '../utils/rbac.js';
// FILE: src/middleware/requirePerm.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 => ... })
* scope: 'global' | 'org' | 'project'
* requirePerm('rfas.view', { projectParam: 'project_id', orgParam: 'org_id' })
* - 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) => {
try {
const orgId = getOrgId ? await getOrgId(req) : null;
const projectId = getProjectId ? await getProjectId(req) : null;
const p = req.principal;
if (!p) return res.status(401).json({ error: "Unauthenticated" });
if (canPerform(req.principal, permCode, { scope, orgId, projectId })) return next();
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' });
if (!(p.is_superadmin || p.permissions?.has?.(permCode))) {
return res.status(403).json({ error: "FORBIDDEN", need: permCode });
}
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();
};
}

View File

@@ -1,27 +1,23 @@
// src/routes/admin.js
import { Router } from 'express';
import sequelize from '../db/index.js';
import { requireAuth } from '../middleware/auth.js';
import { requirePermission } from '../middleware/perm.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';
// FILE: 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";
const r = Router();
// GET /api/admin/sysinfo → ต้องมี admin.read
r.get('/sysinfo',
requirePerm(PERM.admin.read, { scope: 'global' }),
async (req, res) => {
/**
* GET /api/admin/sysinfo
* perm: admin.access (ORG scope) ใช้สิทธิ์กลุ่ม admin
*/
r.get(
"/sysinfo",
requirePerm("admin.access", { orgParam: "org_id" }),
async (_req, res) => {
try {
const [[{ now }]] = await sql.query('SELECT NOW() AS now');
await sql.query("SELECT 1");
res.json({
now,
now: new Date().toISOString(),
node: process.version,
platform: os.platform(),
arch: os.arch(),
@@ -29,80 +25,70 @@ r.get('/sysinfo',
uptime_sec: os.uptime(),
loadavg: os.loadavg(),
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) {
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',
requirePerm(PERM.admin.maintain, { scope: 'global' }),
/**
* POST /api/admin/maintenance/reindex
* perm: settings.manage (GLOBAL) งานดูแลระบบ
*/
r.post(
"/maintenance/reindex",
requirePerm("settings.manage"),
async (_req, res) => {
// ตัวอย่าง: ANALYZE/OPTIMIZE ตารางสำคัญ (ปรับตามจริง)
try {
await sql.query('ANALYZE TABLE correspondences, rfas, drawings');
// ปรับตามตารางจริงของคุณ
await sql.query("ANALYZE TABLE correspondences, rfas, drawings");
res.json({ ok: 1 });
} 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;
/**
* 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;

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

View File

@@ -1,57 +1,100 @@
// src/routes/auth.js (ESM)
import { Router } from 'express';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import sql from '../db/index.js';
// FILE: backend/src/routes/auth.js (ESM) — Bearer only, refresh via header/body, forgot/reset password
import { Router } from "express";
import jwt from "jsonwebtoken";
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 JWT_SECRET = process.env.JWT_SECRET || 'dev-access-secret';
const REFRESH_SECRET = process.env.REFRESH_SECRET || 'dev-refresh-secret';
const ACCESS_TTL = process.env.ACCESS_TTL || '30m'; // 30 นาที
const REFRESH_TTL = process.env.REFRESH_TTL || '30d'; // 30 วัน
/* =========================
* CONFIG & HELPERS
* ========================= */
// ใช้ค่าเดียวกับ 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) {
return jwt.sign(
{ user_id: user.user_id, username: user.username },
JWT_SECRET,
{ expiresIn: ACCESS_TTL, issuer: 'dms-backend' }
{ expiresIn: ACCESS_TTL, issuer: "dms-backend" }
);
}
function signRefreshToken(user) {
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,
{ 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) {
const [[u]] = await sql.query(
'SELECT user_id, username, password_hash, email, first_name, last_name FROM users WHERE username=?',
const [rows] = await sql.query(
`SELECT user_id, username, email, first_name, last_name, password_hash
FROM users WHERE username=? LIMIT 1`,
[username]
);
return u || null;
return rows?.[0] || null;
}
// POST /api/auth/login
r.post('/login', async (req, res) => {
async function findUserByEmail(email) {
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 || {};
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);
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 || '');
if (!ok) 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" });
const access_token = signAccessToken(user);
const token = signAccessToken(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,
user: {
user_id: user.user_id,
@@ -62,53 +105,175 @@ r.post('/login', async (req, res) => {
},
});
});
// POST /api/auth/refresh
r.post('/refresh', async (req, res) => {
const { refresh_token } = req.body || {};
if (!refresh_token) return res.status(400).json({ error: 'refresh_token required' });
/* =========================
* 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" });
}
try {
const payload = jwt.verify(refresh_token, REFRESH_SECRET, { issuer: 'dms-backend' });
if (payload.t !== 'refresh') throw new Error('bad token');
const payload = jwt.verify(refreshToken, REFRESH_SECRET, {
issuer: "dms-backend",
});
if (payload.t !== "refresh") throw new Error("bad token type");
// ยืนยันผู้ใช้ยังอยู่ในระบบ
const [[user]] = await sql.query(
'SELECT user_id, username FROM users WHERE user_id=?',
`SELECT user_id, username, email, first_name, last_name
FROM users WHERE user_id=? LIMIT 1`,
[payload.user_id]
);
if (!user) return res.status(401).json({ error: 'USER_NOT_FOUND' });
if (!user) return res.status(401).json({ error: "USER_NOT_FOUND" });
// rotation
const token = signAccessToken(user);
const new_refresh = signRefreshToken(user); // rotation
res.json({ token, refresh_token: new_refresh });
} catch (e) {
return res.status(401).json({ error: 'INVALID_REFRESH', message: e?.message });
const new_refresh = signRefreshToken(user);
// 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]
);
// บันทึก token ใหม่
await sql.query(
`INSERT INTO password_resets (user_id, token_hash, expires_at)
VALUES (?,?,?)`,
[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) => {
// หากต้องการ blacklist/whitelist refresh token ให้เพิ่มตารางและบันทึกที่นี่
res.json({ ok: 1 });
});
// POST /api/auth/change-password
r.post('/change-password', async (req, res) => {
const { username, old_password, new_password } = req.body || {};
if (!username || !old_password || !new_password) {
return res.status(400).json({ error: 'username, old_password, new_password required' });
/* =========================
* POST /api/auth/reset-password
* - รับ token (จากลิงก์ในอีเมล) + new_password
* - ตรวจ token_hash กับ DB, หมดอายุหรือถูกใช้ไปแล้วหรือยัง
* - เปลี่ยนรหัสผ่าน/ปิดใช้ token
* ========================= */
r.post("/reset-password", async (req, res) => {
const { token, new_password } = req.body || {};
if (!token || !new_password) {
return res.status(400).json({ error: "TOKEN_AND_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 || '');
if (!ok) return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
const token_hash = crypto.createHash("sha256").update(token).digest("hex");
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 hash = await bcrypt.hash(new_password, salt);
await sql.query('UPDATE users SET password_hash=? WHERE user_id=?', [hash, user.user_id]);
res.json({ ok: 1 });
await sql.query(`UPDATE users SET password_hash=? WHERE user_id=?`, [
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;
// หมายเหตุ: คุณอาจเพิ่ม /register, /forgot-password, /reset-password ตามต้องการ
// แต่ในโปรเจกต์นี้จะให้แอดมินสร้างบัญชีผู้ใช้ผ่าน /api/users แทน

View File

@@ -1,19 +1,20 @@
import { Router } from 'express';
import { requireAuth, enrichRoles } from '../middleware/auth.js';
const r = Router();
r.get('/auth/me', requireAuth, enrichRoles, async (req, res) => {
res.json({
user_id: req.user?.user_id,
username: req.user?.username,
roles: req.user?.roles || []
// FILE: src/routes/auth_extras.js
// Deprecated for this project (เราใช้ Bearer + authJwt() แล้ว)
import jwt from "jsonwebtoken";
const JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || "dev-access-secret";
export function requireAuth(req, res, next) {
const token = req.cookies?.access_token;
if (!token) return res.status(401).json({ error: "Unauthenticated" });
try {
const payload = jwt.verify(token, JWT_ACCESS_SECRET, {
issuer: "dms-backend",
});
});
// Placeholder: client can simply drop tokens; provided for symmetry/logging hook
r.post('/auth/logout', requireAuth, async (_req, res) => {
res.json({ ok: true });
});
export default r;
req.user = { user_id: payload.user_id, username: payload.username };
return next();
} catch {
return res.status(401).json({ error: "INVALID_TOKEN" });
}
}
export function requireRole(_role) {
return (_req, res, next) => res.status(403).json({ error: "FORBIDDEN" });
}

View File

@@ -1,66 +1,62 @@
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import PERM from '../config/permissions.js';
// FILE: src/routes/categories.js
// อ่าน: ใช้ organizations.view (GLOBAL)
// สร้าง/แก้/ลบ: ใช้ settings.manage (GLOBAL)
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
// Category LIST (global master, not scoped) still require permission
r.get('/categories',
requirePerm(PERM.category.read, { scope: 'global' }),
async (req, res) => {
const [rows] = await sql.query('SELECT * FROM categories ORDER BY cat_id DESC');
res.json(rows);
}
);
r.post('/categories',
requirePerm(PERM.category.create, { scope: 'global' }),
async (req, res) => {
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 });
}
);
r.put('/categories/:id',
requirePerm(PERM.category.update, { scope: 'global' }),
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=?', [cat_name, id]);
res.json({ ok: 1 });
}
);
r.delete('/categories/:id',
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);
}
// Categories
r.get("/categories", requirePerm("organizations.view"), async (_req, res) => {
const [rows] = await sql.query(
"SELECT * FROM categories ORDER BY cat_id DESC"
);
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" });
const [rs] = await sql.query(
"INSERT INTO categories (cat_code, cat_name) VALUES (?,?)",
[cat_code, cat_name]
);
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=?", [
cat_name,
id,
]);
res.json({ ok: 1 });
});
r.delete(
"/categories/:id",
requirePerm("settings.manage"),
async (req, res) => {
const id = Number(req.params.id);
await sql.query("DELETE FROM categories WHERE cat_id=?", [id]);
res.json({ ok: 1 });
}
);
// 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;

View File

@@ -1,74 +1,143 @@
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';
// FILE: src/routes/contract_dwg.js
// ใน seed ยังไม่มี contract_dwg.* → ผูกชั่วคราวกับสิทธิ์กลุ่ม drawings:
// read → drawings.view, create/update/delete → drawings.upload/delete (PROJECT scope)
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
const OWN = ownerResolvers(sql, 'contract_dwg', 'id');
// LIST mappings
r.get('/',
requirePerm(PERM.contract_dwg.read, { scope: 'global' }),
async (req, res) => {
const { project_id, org_id, condwg_no, limit=50, offset=0 } = req.query;
const base = buildScopeWhere(req.principal, { tableAlias: 'm', orgColumn: 'm.org_id', projectColumn: 'm.project_id', permCode: PERM.contract_dwg.read, preferProject: true });
const extra = [];
const params = { ...base.params, limit: Number(limit), offset: Number(offset) };
if (project_id) { extra.push('m.project_id = :project_id'); params.project_id = Number(project_id); }
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; }
const where = [base.where, ...extra].filter(Boolean).join(' AND ');
const [rows] = await sql.query(`SELECT m.* FROM contract_dwg m WHERE ${where} ORDER BY m.id DESC LIMIT :limit OFFSET :offset`, params);
res.json(rows);
}
// LIST (PROJECT scope enforce via params ifส่ง project_id, ไม่ส่งจะถูก filter ด้วย scope ภายใน)
r.get(
"/",
requirePerm("drawings.view", { projectParam: "project_id" }),
async (req, res) => {
const { project_id, org_id, condwg_no, limit = 50, offset = 0 } = req.query;
const p = req.principal;
const params = [];
const cond = [];
// ABAC filter ฝั่ง server กันหลุดขอบเขต
if (!p.is_superadmin) {
if (project_id) {
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
r.get('/:id',
requirePerm(PERM.contract_dwg.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
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' });
res.json(row);
}
// CREATE
r.post(
"/",
requirePerm("drawings.upload", { projectParam: "project_id" }),
async (req, res) => {
const {
org_id,
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
r.put('/:id',
requirePerm(PERM.contract_dwg.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
const id = Number(req.params.id);
const { title, remark } = req.body;
await sql.query('UPDATE contract_dwg SET title=?, remark=? WHERE id=?', [title, remark, id]);
res.json({ ok: 1 });
}
);
r.put("/:id", requirePerm("drawings.upload"), 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" });
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
r.delete('/:id',
requirePerm(PERM.contract_dwg.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
const id = Number(req.params.id);
await sql.query('DELETE FROM contract_dwg WHERE id=?', [id]);
res.json({ ok: 1 });
}
);
r.delete("/:id", requirePerm("drawings.delete"), 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" });
await sql.query("DELETE FROM contract_dwg WHERE id=?", [id]);
res.json({ ok: 1 });
});
export default r;

View File

@@ -1,72 +1,141 @@
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';
// FILE: src/routes/contracts.js
// ไม่มี contract.* ใน seed → map เป็นงานดูแลองค์กร/โปรเจ็กต์:
// list/get → projects.view (ORG)
// create/update/delete → projects.manage (ORG)
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
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('/',
requirePerm(PERM.contract.read, { scope: 'global' }),
async (req, res) => {
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 });
const extra = [];
const params = { ...base.params, limit: Number(limit), offset: Number(offset) };
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 (contract_no){ extra.push('c.contract_no = :contract_no'); params.contract_no = contract_no; }
if (q) { extra.push('(c.contract_no LIKE :q OR c.title LIKE :q)'); params.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);
}
if (project_id) {
cond.push("c.project_id=?");
params.push(Number(project_id));
}
if (contract_no) {
cond.push("c.contract_no=?");
params.push(contract_no);
}
if (q) {
cond.push("(c.contract_no LIKE ? OR c.title LIKE ?)");
params.push(`%${q}%`, `%${q}%`);
}
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);
}
);
r.get('/:id',
requirePerm(PERM.contract.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
const id = Number(req.params.id);
const [[row]] = await sql.query('SELECT * FROM contracts WHERE id=?', [id]);
if (!row) return res.status(404).json({ error: 'Not found' });
res.json(row);
}
// GET
r.get(
"/:id",
requirePerm("projects.view", { orgParam: "org_id" }),
async (req, res) => {
const id = Number(req.params.id);
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" });
res.json(row);
}
);
r.post('/',
requirePerm(PERM.contract.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
async (req, res) => {
const { org_id, project_id, contract_no, title, status } = req.body;
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]);
res.json({ id: rs.insertId });
}
// CREATE
r.post(
"/",
requirePerm("projects.manage", { orgParam: "org_id" }),
async (req, res) => {
const { org_id, project_id, contract_no, title, status } = req.body || {};
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 });
}
);
r.put('/:id',
requirePerm(PERM.contract.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
const id = Number(req.params.id);
const { title, status } = req.body;
await sql.query('UPDATE contracts SET title=?, status=? WHERE id=?', [title, status, id]);
res.json({ ok: 1 });
}
// UPDATE
r.put(
"/:id",
requirePerm("projects.manage", { orgParam: "org_id" }),
async (req, res) => {
const id = Number(req.params.id);
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" });
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 });
}
);
r.delete('/:id',
requirePerm(PERM.contract.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
const id = Number(req.params.id);
await sql.query('DELETE FROM contracts WHERE id=?', [id]);
res.json({ ok: 1 });
}
// DELETE
r.delete(
"/:id",
requirePerm("projects.manage", { orgParam: "org_id" }),
async (req, res) => {
const id = Number(req.params.id);
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;

View File

@@ -1,74 +1,124 @@
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';
// FILE: backend/src/routes/correspondences.js
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
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('/',
requirePerm(PERM.correspondence.read, { scope: 'global' }),
async (req, res) => {
const { project_id, org_id, q, limit = 50, offset = 0 } = req.query;
const base = buildScopeWhere(req.principal, {
tableAlias: 'c', orgColumn: 'c.org_id', projectColumn: 'c.project_id',
permCode: PERM.correspondence.read, preferProject: true,
if (!p.is_superadmin) {
if (project_id) {
if (!p.inProject(Number(project_id)))
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
cond.push("c.project_id=?");
params.push(Number(project_id));
} 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) };
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) { extra.push('(c.corr_no LIKE :q OR c.subject LIKE :q)'); params.q = `%${q}%`; }
const where = [base.where, ...extra].join(' AND ');
const [rows] = await sql.query(`SELECT c.* FROM correspondences c WHERE ${where} ORDER BY c.id DESC LIMIT :limit OFFSET :offset`, params);
res.json(rows);
}
// CREATE
r.post(
"/",
requirePerm("corr.manage", { projectParam: "project_id" }),
async (req, res) => {
const { org_id, project_id, corr_no, subject, status } = req.body || {};
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',
requirePerm(PERM.correspondence.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
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' });
res.json(row);
}
);
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 });
}
);
const { subject, status } = req.body || {};
await sql.query("UPDATE correspondences SET subject=?, status=? WHERE id=?", [
subject ?? row.subject,
status ?? row.status,
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;

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

View File

@@ -1,58 +1,149 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth.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 DocumentModel from '../db/models/Document.js';
// FILE: backend/src/routes/documents.js
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
const Doc = DocumentModel(sequelize);
r.get('/documents', requireAuth, 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;
// LIST
r.get(
"/",
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 = {};
if (project_id) where.project_id = project_id;
if (status) where.status = status;
if (category) where.category = category;
if (q) where.title = sequelize.where(sequelize.fn('LOWER', sequelize.col('title')), 'LIKE', `%${String(q).toLowerCase()}%`);
const p = req.principal;
const params = [];
const cond = [];
const { rows, count } = await Doc.findAndCountAll({ where, limit, offset, order:[['created_at','DESC']] });
res.json({ items: rows, total: count, page: Number(page), page_size: limit });
});
if (!p.is_superadmin) {
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) => {
const row = await Doc.findByPk(Number(req.params.id));
if (!row) return res.status(404).json({ error: 'Not found' });
if (status) {
cond.push("d.status=?");
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);
});
r.post('/documents', requireAuth, enrichPermissions(), requireProjectMembershipFromBody(), enrichPermissions(), requirePerm('document:create'), async (req, res) => {
// CREATE
r.post(
"/",
requirePerm("documents.manage", { projectParam: "project_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 created = await Doc.create({ project_id, doc_no, title, category, status, created_by: req.user?.user_id });
res.status(201).json({ document_id: created.document_id });
});
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 || {};
if (title !== undefined) row.title = title;
if (category !== undefined) row.category = category;
if (status !== undefined) row.status = status;
row.updated_by = req.user?.user_id;
await row.save();
await sql.query(
"UPDATE documents SET title=?, category=?, status=?, updated_by=? WHERE document_id=?",
[
title ?? row.title,
category ?? row.category,
status ?? row.status,
req.principal.user_id,
id,
]
);
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) => {
const row = await Doc.findByPk(Number(req.params.id));
if (!row) return res.status(404).json({ error: 'Not found' });
await row.destroy();
// DELETE
r.delete("/: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" });
await sql.query("DELETE FROM documents WHERE document_id=?", [id]);
res.json({ ok: true });
});

View File

@@ -1,85 +1,120 @@
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';
// FILE: backend/src/routes/drawings.js
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
const OWN = ownerResolvers(sql, 'drawings', 'id');
// LIST
r.get('/',
requirePerm('drawing.read', { scope: 'global' }),
r.get(
"/",
requirePerm("drawings.view", { projectParam: "project_id" }),
async (req, res) => {
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, {
tableAlias: 'd', orgColumn: 'd.org_id', projectColumn: 'd.project_id',
permCode: 'drawing.read', preferProject: true,
});
if (!p.is_superadmin) {
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));
}
const extra = [];
const params = { ...base.params, limit: Number(limit), offset: Number(offset) };
if (project_id) { extra.push('d.project_id = :project_id'); params.project_id = Number(project_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 (q) { extra.push('(d.dwg_no LIKE :q OR d.title LIKE :q)'); params.q = `%${q}%`; }
const where = [base.where, ...extra].filter(Boolean).join(' AND ');
if (org_id) {
cond.push("d.org_id=?");
params.push(Number(org_id));
}
if (code) {
cond.push("d.dwg_code=?");
params.push(code);
}
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(
`SELECT d.* FROM drawings d WHERE ${where}
ORDER BY d.id DESC LIMIT :limit OFFSET :offset`,
params
`SELECT d.* FROM drawings d ${where} ORDER BY d.id DESC LIMIT ? OFFSET ?`,
[...params, Number(limit), Number(offset)]
);
res.json(rows);
}
);
// GET
r.get('/:id',
requirePerm('drawing.read', { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
r.get("/:id", requirePerm("drawings.view"), async (req, res) => {
const id = Number(req.params.id);
const [[row]] = await sql.query('SELECT * FROM drawings WHERE id=?', [id]);
if (!row) return res.status(404).json({ error: 'Not found' });
const [[row]] = await sql.query("SELECT * FROM drawings 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('drawing.create', { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
r.post(
"/",
requirePerm("drawings.upload", { projectParam: "project_id" }),
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(
`INSERT INTO drawings (org_id, project_id, dwg_no, dwg_code, title, created_by)
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 });
}
);
// UPDATE
r.put('/:id',
requirePerm('drawing.update', { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
// UPDATE (ใช้สิทธิ์ drawings.upload)
r.put("/:id", requirePerm("drawings.upload"), async (req, res) => {
const id = Number(req.params.id);
const { title } = req.body;
await sql.query('UPDATE drawings SET title=? WHERE id=?', [title, id]);
const [[row]] = await sql.query("SELECT * FROM drawings 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 { title } = req.body || {};
await sql.query("UPDATE drawings SET title=? WHERE id=?", [
title ?? row.title,
id,
]);
res.json({ ok: 1 });
}
);
});
// DELETE
r.delete('/:id',
requirePerm('drawing.delete', { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
r.delete("/:id", requirePerm("drawings.delete"), async (req, res) => {
const id = Number(req.params.id);
await sql.query('DELETE FROM drawings WHERE id=?', [id]);
const [[row]] = await sql.query("SELECT * FROM drawings 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 drawings WHERE id=?", [id]);
res.json({ ok: 1 });
}
);
});
export default r;

View File

@@ -1,91 +1,154 @@
import { Router } from 'express';
import fs from 'fs';
import path from 'path';
import jwt from 'jsonwebtoken';
import { requireAuth } from '../middleware/auth.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';
// FILE: backend/src/routes/files_extras.js
// NOTE: generic file actions ผูกสิทธิ์ตามโมดูลปลายทาง และบังคับ ABAC จาก project_id ของเรคคอร์ด
import { Router } from "express";
import fs from "node:fs";
import sql from "../db/index.js";
const r = Router();
const Files = FileModel(sequelize);
async function projectForFile(rec) {
const mod = rec.module; const refId = rec.ref_id;
switch (mod) {
case 'rfa': { const M = (await import('../db/models/RFA.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; }
case 'correspondence': { const M = (await import('../db/models/Correspondence.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; }
case 'drawing': { const M = (await import('../db/models/Drawing.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; }
case 'document': { const M = (await import('../db/models/Document.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; }
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;
// โปรเจ็คของไฟล์อิงโมดูล/ตารางอ้างอิง
switch (rec.module) {
case "rfa": {
const [[row]] = await sql.query(
"SELECT project_id FROM rfas WHERE id=?",
[rec.ref_id]
);
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
r.head('/files/:file_id', requireAuth, async (req, res) => {
const rec = await Files.findByPk(Number(req.params.file_id));
// HEAD meta
r.head("/files/:file_id", async (req, res) => {
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();
res.setHeader('Content-Type', rec.mime || 'application/octet-stream');
res.setHeader('Content-Length', String(rec.size || 0));
res.setHeader("Content-Type", rec.mime || "application/octet-stream");
res.setHeader("Content-Length", String(rec.size || 0));
res.status(200).end();
});
// delete (soft delete is recommended; here we do physical delete + record delete)
r.delete('/files/:file_id', requireAuth, enrichPermissions(), requirePerm('file:delete'), async (req, res) => {
const rec = await Files.findByPk(Number(req.params.file_id));
if (!rec) return res.status(404).json({ error: 'Not found' });
// DELETE
r.delete("/files/:file_id", async (req, res) => {
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).json({ error: "Not found" });
const p = req.principal;
if (!p) return res.status(401).json({ error: "Unauthenticated" });
const pid = await projectForFile(rec);
const roles = req.user?.roles || [];
const isAdmin = roles.includes('Admin');
if (!isAdmin) {
const { getUserProjectIds } = await import('../middleware/abac.js');
const memberProjects = await getUserProjectIds(req.user?.user_id);
if (!memberProjects.includes(pid)) return res.status(403).json({ error: 'Forbidden: not a project member' });
if (!p.is_superadmin) {
if (!pid || !p.inProject(pid))
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
const need = permForFile(rec, "delete");
if (!p.can?.(need))
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 });
});
// rename (meta only - keep disk file name)
r.post('/files/:file_id/rename', requireAuth, enrichPermissions(), requirePerm('file:update'), async (req, res) => {
const rec = await Files.findByPk(Number(req.params.file_id));
if (!rec) return res.status(404).json({ error: 'Not found' });
// RENAME (meta only)
r.post("/files/:file_id/rename", async (req, res) => {
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).json({ error: "Not found" });
const p = req.principal;
if (!p) return res.status(401).json({ error: "Unauthenticated" });
const pid = await projectForFile(rec);
const roles = req.user?.roles || [];
const isAdmin = roles.includes('Admin');
if (!isAdmin) {
const { getUserProjectIds } = await import('../middleware/abac.js');
const memberProjects = await getUserProjectIds(req.user?.user_id);
if (!memberProjects.includes(pid)) return res.status(403).json({ error: 'Forbidden: not a project member' });
if (!p.is_superadmin) {
if (!pid || !p.inProject(pid))
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
const need = permForFile(rec, "update");
if (!p.can?.(need))
return res.status(403).json({ error: "FORBIDDEN", need });
}
const { orig_name } = req.body || {};
if (!orig_name) return res.status(400).json({ error: 'orig_name required' });
rec.orig_name = orig_name;
await rec.save();
if (!orig_name) return res.status(400).json({ error: "orig_name required" });
await sql.query("UPDATE files SET orig_name=? WHERE file_id=?", [
orig_name,
id,
]);
res.json({ ok: true });
});
// refresh signed download url
r.post('/files/:file_id/refresh-url', requireAuth, async (req, res) => {
const rec = await Files.findByPk(Number(req.params.file_id));
if (!rec) return res.status(404).json({ error: 'Not found' });
// refresh signed download URL ปกติใช้ signed URL service ภายนอก; ที่นี่คืน URL ภายในเป็นตัวอย่าง
r.post("/files/:file_id/refresh-url", async (req, res) => {
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).json({ error: "Not found" });
const p = req.principal;
if (!p) return res.status(401).json({ error: "Unauthenticated" });
const pid = await projectForFile(rec);
const roles = req.user?.roles || [];
const isAdmin = roles.includes('Admin');
if (!isAdmin) {
const { getUserProjectIds } = await import('../middleware/abac.js');
const memberProjects = await getUserProjectIds(req.user?.user_id);
if (!memberProjects.includes(pid)) return res.status(403).json({ error: 'Forbidden: not a project member' });
if (!p.is_superadmin) {
if (!pid || !p.inProject(pid))
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
const need = permForFile(rec, "read");
if (!p.can?.(need))
return res.status(403).json({ error: "FORBIDDEN", need });
}
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/v1/files/${rec.file_id}?token=${token}`;
const download_url = `/api/files/${rec.file_id}?token=internal-placeholder&exp=${expSec}`;
res.json({ download_url, expires_in: expSec });
});

View File

@@ -1,13 +1,19 @@
import { Router } from 'express';
import { sequelize } from '../db/sequelize.js';
// FILE: backend/src/routes/health.js
import { Router } from "express";
import sql from "../db/index.js";
const r = Router();
r.get('/health', async (_req, res) => {
// /api/health — ไม่ต้องใช้สิทธิ์
r.get("/health", async (_req, res) => {
try {
await sequelize.query('SELECT 1 AS ok');
res.status(200).json({ ok: true, db: 'up' });
const [[{ now }]] = await sql.query("SELECT NOW() AS now");
res.status(200).json({ ok: true, db: "up", now });
} 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;

View File

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

View File

@@ -1,120 +1,125 @@
// src/routes/lookup.js (ESM)
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import { buildScopeWhere } from '../utils/scope.js';
import PERM from '../config/permissions.js';
// FILE: backend/src/routes/lookup.js
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
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
r.get('/',
// ต้องเป็นผู้ใช้ที่ยืนยันตัวตนแล้ว (middleware authJwt+loadPrincipal อยู่ใน /api)
// ไม่ require permission เดียว เพราะเราจะเช็คทีละชุดด้านล่าง
async (req, res) => {
const pick = new Set(parsePick(req.query.pick) || [
'org', 'project', 'category', 'subcategory', 'volume', 'permission'
]);
const result = {};
// 1) Organizations (scoped list) — require organization.read
if (pick.has('org')) {
// มีสิทธิ์ถึงจะดึง
const canOrg = req.principal.isSuperAdmin || req.principal.perms.has(PERM.organization.read);
if (canOrg) {
const { where, params } = buildScopeWhere(req.principal, {
tableAlias: 'o',
orgColumn: 'o.org_id',
projectColumn: 'NULL',
permCode: PERM.organization.read,
});
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;
} else {
result.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
r.get("/", async (req, res) => {
const picks = new Set(
String(
req.query.pick || "org,project,category,subcategory,volume,permission"
)
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
);
result.projects = rows;
} else {
result.projects = [];
const out = {};
// Organizations — GLOBAL (อ่านได้ด้วย organizations.view)
if (picks.has("org")) {
try {
// มี perm ไหม? (GLOBAL)
const ok =
req.principal?.is_superadmin ||
req.principal?.permissions?.has?.("organizations.view");
out.organizations = ok
? (
await sql.query(
"SELECT org_id, org_name FROM organizations ORDER BY org_name"
)
)[0]
: [];
} catch {
out.organizations = [];
}
}
// 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;
// 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 {
result.categories = [];
rows = [];
}
out.projects = rows;
}
} catch {
out.projects = [];
}
}
// 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 = [];
// 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 = [];
}
}
// 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 = [];
}
// 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]
: [];
}
// 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);
}
);
res.json(out);
});
export default r;

View File

@@ -1,83 +1,163 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth.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 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';
// FILE: backend/src/routes/maps.js
// Map ความสัมพันธ์ระหว่าง RFA<->Drawing และ Correspondence<->Document
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
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) {
const rfaId = Number(req.params.rfa_id);
const row = await RFA.findByPk(rfaId);
if (!row) { res.status(404).json({ error:'RFA not found' }); return false; }
const roles = req.user?.roles || []; const isAdmin = roles.includes('Admin');
if (isAdmin) return true;
const { getUserProjectIds } = await import('../middleware/abac.js');
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;
}
async function ensureCorrMembership(req, res) {
const corrId = Number(req.params.corr_id);
const row = await Corr.findByPk(corrId);
if (!row) { res.status(404).json({ error:'Correspondence not found' }); return false; }
const roles = req.user?.roles || []; const isAdmin = roles.includes('Admin');
if (isAdmin) return true;
const { getUserProjectIds } = await import('../middleware/abac.js');
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;
}
// RFA <-> Drawing
r.get('/maps/rfa/:rfa_id/drawings', requireAuth, async (req, res) => {
const rows = await RfaDraw.findAll({ where: { rfa_id: Number(req.params.rfa_id) } });
// ========= RFA <-> Drawing =========
// LIST
r.get(
"/maps/rfa/:rfa_id/drawings",
requirePerm("rfas.view", { projectParam: "project_id" }), // ABAC enforced เมื่อส่ง query project_id; ถ้าไม่ส่งเราจะตรวจจากเรคคอร์ด
async (req, res) => {
const rfa_id = Number(req.params.rfa_id);
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" });
}
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);
});
r.post('/maps/rfa/:rfa_id/drawings/:drawing_id', requireAuth, enrichPermissions(), requirePerm('rfa:update'), async (req, res) => {
if (!(await ensureRfaMembership(req, res))) return;
const { rfa_id, drawing_id } = { rfa_id: Number(req.params.rfa_id), drawing_id: Number(req.params.drawing_id) };
await RfaDraw.create({ rfa_id, drawing_id });
res.status(201).json({ ok: true });
});
r.delete('/maps/rfa/:rfa_id/drawings/:drawing_id', requireAuth, enrichPermissions(), requirePerm('rfa:update'), async (req, res) => {
if (!(await ensureRfaMembership(req, res))) return;
const { rfa_id, drawing_id } = { rfa_id: Number(req.params.rfa_id), drawing_id: Number(req.params.drawing_id) };
const count = await RfaDraw.destroy({ where: { rfa_id, drawing_id } });
res.json({ ok: count > 0 });
});
}
);
// Correspondence <-> Document
r.get('/maps/correspondence/:corr_id/documents', requireAuth, async (req, res) => {
const rows = await CorrDoc.findAll({ where: { correspondence_id: Number(req.params.corr_id) } });
res.json(rows);
});
r.post('/maps/correspondence/:corr_id/documents/:doc_id', requireAuth, enrichPermissions(), requirePerm('correspondence:update'), async (req, res) => {
if (!(await ensureCorrMembership(req, res))) return;
const { corr_id, doc_id } = { corr_id: Number(req.params.corr_id), doc_id: Number(req.params.doc_id) };
await CorrDoc.create({ correspondence_id: corr_id, document_id: doc_id });
// ADD
r.post(
"/maps/rfa/:rfa_id/drawings/:drawing_id",
requirePerm("rfas.respond", { projectParam: "project_id" }),
async (req, res) => {
const rfa_id = Number(req.params.rfa_id);
const drawing_id = Number(req.params.drawing_id);
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 });
});
r.delete('/maps/correspondence/:corr_id/documents/:doc_id', requireAuth, enrichPermissions(), requirePerm('correspondence:update'), async (req, res) => {
if (!(await ensureCorrMembership(req, res))) return;
const { corr_id, doc_id } = { corr_id: Number(req.params.corr_id), doc_id: Number(req.params.doc_id) };
const count = await CorrDoc.destroy({ where: { correspondence_id: corr_id, document_id: doc_id } });
res.json({ ok: count > 0 });
});
}
);
// REMOVE
r.delete(
"/maps/rfa/:rfa_id/drawings/:drawing_id",
requirePerm("rfas.respond"),
async (req, res) => {
const rfa_id = Number(req.params.rfa_id);
const drawing_id = Number(req.params.drawing_id);
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" });
}
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 =========
r.get(
"/maps/correspondence/:corr_id/documents",
requirePerm("corr.view", { projectParam: "project_id" }),
async (req, res) => {
const corr_id = Number(req.params.corr_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 [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;

View File

@@ -1,19 +1,96 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth.js';
import { sequelize } from '../db/sequelize.js';
import FileModel from '../db/models/FileObject.js';
// FILE: backend/src/routes/module_files.js
import { Router } from "express";
import sql from "../db/index.js";
const r = Router();
const Files = FileModel(sequelize);
async function listBy(mod, ref_id) {
return Files.findAll({ where: { module: mod, ref_id }, order:[['created_at','DESC']] });
// อ่านไฟล์ของแต่ละโมดูล โดยเช็ค ABAC + permission จาก principal
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']) {
r.get(`/${mod}s/:id/files`, requireAuth, async (req, res) => {
const items = await listBy(mod, Number(req.params.id));
res.json(items);
// /:module(s)/:id/files
for (const mod of [
"rfa",
"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);
});
}

View File

@@ -1,66 +1,115 @@
// src/routes/map.js
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';
// FILE: backend/src/routes/mvp.js
// (generic entity maps — ใช้ projects.view อ่าน และ projects.manage เขียน/ลบ)
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
const OWN = ownerResolvers(sql, 'entity_maps', 'id');
// LIST
r.get('/',
requirePerm(PERM.map.read, { scope: 'global' }),
// LIST — projects.view (ORG scope)
r.get(
"/",
requirePerm("projects.view", { orgParam: "org_id" }),
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, {
tableAlias: 'm',
orgColumn: 'm.org_id',
projectColumn: 'm.project_id',
permCode: PERM.map.read,
preferProject: true,
});
if (!p.is_superadmin) {
if (org_id) {
if (!p.inOrg(Number(org_id)))
return res.status(403).json({ error: "FORBIDDEN_ORG" });
cond.push("m.org_id=?");
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 = [];
const params = { ...base.params, limit: Number(limit), offset: Number(offset) };
if (project_id) { extra.push('m.project_id = :project_id'); params.project_id = 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 (src_type) { extra.push('m.src_type = :src_type'); params.src_type = src_type; }
if (dst_type) { extra.push('m.dst_type = :dst_type'); params.dst_type = dst_type; }
if (project_id) {
cond.push("m.project_id=?");
params.push(Number(project_id));
}
if (module) {
cond.push("m.module=?");
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(
`SELECT m.* FROM entity_maps m
WHERE ${where}
ORDER BY m.id DESC LIMIT :limit OFFSET :offset`,
params
`SELECT m.* FROM entity_maps m ${where} ORDER BY m.id DESC LIMIT ? OFFSET ?`,
[...params, Number(limit), Number(offset)]
);
res.json(rows);
}
);
// CREATE
r.post('/',
requirePerm(PERM.map.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
// CREATE — projects.manage (ORG scope)
r.post(
"/",
requirePerm("projects.manage", { orgParam: "org_id" }),
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(
`INSERT INTO entity_maps (org_id, project_id, module, src_type, src_id, dst_type, dst_id, remark, created_by)
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 });
}
);
// DELETE (by id)
r.delete('/:id',
requirePerm(PERM.map.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
// DELETE — projects.manage (ORG scope)
r.delete(
"/:id",
requirePerm("projects.manage", { orgParam: "org_id" }),
async (req, res) => {
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 });
}
);

View File

@@ -1,28 +1,29 @@
import { Router } from 'express';
import { sequelize } from '../db/sequelize.js';
import fs from 'fs';
import path from 'path';
// FILE: backend/src/routes/ops.js
import { Router } from "express";
import sql from "../db/index.js";
import fs from "node:fs";
import path from "node:path";
const r = Router();
r.get('/ready', async (_req, res) => {
r.get("/ready", async (_req, res) => {
try {
await sequelize.query('SELECT 1');
return res.json({ ready: true });
await sql.query("SELECT 1");
res.json({ ready: true });
} 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 {
const pkgPath = path.resolve(process.cwd(), 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
const pkgPath = path.resolve(process.cwd(), "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
res.json({ name: pkg.name, version: pkg.version });
} catch {
res.json({ name: 'dms-backend', version: 'unknown' });
res.json({ name: "dms-backend", version: "unknown" });
}
});

View File

@@ -1,41 +1,52 @@
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import { buildScopeWhere } from '../utils/scope.js';
import PERM from '../config/permissions.js';
// FILE: backend/src/routes/organizations.js
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
// LIST (org) ดูเฉพาะ org ใน scope
r.get('/',
requirePerm('organization.read', { scope: 'global' }),
async (req, res) => {
const { where, params } = buildScopeWhere(req.principal, {
tableAlias: 'o', orgColumn: 'o.org_id', projectColumn: 'NULL',
permCode: 'organization.read',
});
// LIST
r.get("/", requirePerm("organizations.view"), async (_req, res) => {
const [rows] = await sql.query(
`SELECT o.* FROM organizations o WHERE ${where}`,
params
"SELECT * FROM organizations ORDER BY org_name"
);
res.json(rows);
}
);
});
// GET by id
r.get('/:id',
requirePerm('organization.read', {
scope: 'org',
getOrgId: async req => Number(req.params.id),
}),
async (req, res) => {
// GET
r.get("/:id", requirePerm("organizations.view"), async (req, res) => {
const id = Number(req.params.id);
const [[row]] = await sql.query('SELECT * FROM organizations WHERE org_id=?', [id]);
if (!row) return res.status(404).json({ error: 'Not found' });
const [[row]] = await sql.query(
"SELECT * FROM organizations WHERE org_id=?",
[id]
);
if (!row) return res.status(404).json({ error: "Not found" });
res.json(row);
}
);
});
// CREATE / UPDATE / DELETE — settings.manage
r.post("/", requirePerm("settings.manage"), async (req, res) => {
const { org_name } = req.body || {};
if (!org_name) return res.status(400).json({ error: "org_name required" });
const [rs] = await sql.query(
"INSERT INTO organizations (org_name) VALUES (?)",
[org_name]
);
res.status(201).json({ org_id: rs.insertId });
});
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;

View File

@@ -1,16 +1,16 @@
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import PERM from '../config/permissions.js';
// FILE: backend/src/routes/permissions.js
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
r.get('/',
requirePerm('permission.read', { scope: 'global' }),
async (req, res) => {
const [rows] = await sql.query('SELECT permission_id, permission_code, description FROM permissions ORDER BY permission_code');
// GLOBAL: settings.manage จึงเห็นได้ทั้งหมด
r.get("/", 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);
}
);
});
export default r;

View File

@@ -1,80 +1,122 @@
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import { buildScopeWhere } from '../utils/scope.js';
import PERM from '../config/permissions.js';
// FILE: backend/src/routes/projects.js
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
// LIST จำกัดตาม org/project scope ของผู้ใช้
r.get('/',
requirePerm('project.read', { scope: 'global' }),
// LIST — ORG scope
r.get(
"/",
requirePerm("projects.view", { orgParam: "org_id" }),
async (req, res) => {
const { where, params } = buildScopeWhere(req.principal, {
tableAlias: 'p', orgColumn: 'p.org_id', projectColumn: 'p.project_id',
permCode: 'project.read', preferProject: true,
});
const p = req.principal;
const { org_id } = req.query;
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(
`SELECT p.* FROM projects p WHERE ${where}`,
`SELECT p.* FROM projects p ${where} ORDER BY p.project_name`,
params
);
res.json(rows);
}
);
// GET
r.get('/:id',
requirePerm('project.read', {
scope: 'project',
getProjectId: async req => Number(req.params.id),
}),
// GET — PROJECT scope
r.get(
"/:id",
requirePerm("projects.view", { orgParam: "org_id" }),
async (req, res) => {
const id = Number(req.params.id);
const [[row]] = await sql.query('SELECT * FROM projects WHERE project_id=?', [id]);
if (!row) return res.status(404).json({ error: 'Not found' });
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" });
res.json(row);
}
);
// CREATE
r.post('/',
requirePerm('project.create', {
scope: 'org',
getOrgId: async req => req.body?.org_id ?? null,
}),
// CREATE — ORG scope
r.post(
"/",
requirePerm("projects.manage", { orgParam: "org_id" }),
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(
'INSERT INTO projects (org_id, project_code, project_name) VALUES (?,?,?)',
[org_id, project_code, project_name]
"INSERT INTO projects (org_id, project_code, project_name, created_by) VALUES (?,?,?,?)",
[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
r.put('/:id',
requirePerm('project.update', {
scope: 'project',
getProjectId: async req => Number(req.params.id),
}),
// UPDATE — ORG scope
r.put(
"/:id",
requirePerm("projects.manage", { orgParam: "org_id" }),
async (req, res) => {
const { project_name } = req.body;
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 });
}
);
// DELETE
r.delete('/:id',
requirePerm('project.delete', {
scope: 'project',
getProjectId: async req => Number(req.params.id),
}),
// DELETE — ORG scope
r.delete(
"/:id",
requirePerm("projects.manage", { orgParam: "org_id" }),
async (req, res) => {
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 });
}
);

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

175
backend/src/routes/rbac_admin.js Normal file → Executable file
View File

@@ -1,105 +1,88 @@
// src/routes/rbac_admin.js (ESM)
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import PERM from '../config/permissions.js';
// FILE: backend/src/routes/rbac_admin.js
import { Router } from "express";
import { Role, Permission, UserProjectRole, Project } from "../db/sequelize.js";
import { authJwt } from "../middleware/authJwt.js";
import { loadPrincipalMw } from "../middleware/loadPrincipal.js"; // แก้ไข: import ให้ถูกต้อง
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
const router = Router();
/** LIST: roles */
r.get('/roles',
requirePerm(PERM.rbac_admin.read, { scope: 'global' }),
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);
// Middleware Chain ที่ถูกต้อง 100% ตามสถาปัตยกรรมของคุณ
router.use(authJwt(), loadPrincipalMw());
// == ROLES Management ==
router.get("/roles", requirePerm("roles.manage"), async (req, res, next) => {
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.` });
}
);
/** LIST: permissions */
r.get('/permissions',
requirePerm(PERM.rbac_admin.read, { scope: 'global' }),
async (req, res) => {
const [rows] = await sql.query('SELECT permission_id, permission_code, description FROM permissions ORDER BY permission_code');
res.json(rows);
next(error);
}
);
});
/** LIST: role→permissions */
r.get('/roles/:role_id/permissions',
requirePerm(PERM.rbac_admin.read, { scope: 'global' }),
async (req, res) => {
const role_id = Number(req.params.role_id);
const [rows] = await sql.query(
`SELECT p.permission_id, p.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.permission_code`, [role_id]);
res.json(rows);
}
);
router.put("/roles/:id/permissions", requirePerm("roles.manage"), async (req, res, next) => {
const { permissionIds } = req.body;
if (!Array.isArray(permissionIds)) return res.status(400).json({ message: "permissionIds must be an array." });
try {
const role = await Role.findByPk(req.params.id);
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); }
});
/** MAP: role↔permission (grant/revoke) */
r.post('/roles/:role_id/permissions',
requirePerm(PERM.rbac_admin.grant_perm, { scope: 'global' }),
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 });
}
);
// == USER-PROJECT-ROLES Management ==
router.get("/user-project-roles", requirePerm("users.manage"), async (req, res, next) => {
const { userId } = req.query;
if (!userId) return res.status(400).json({ message: "userId query parameter is required." });
try {
const assignments = await UserProjectRole.findAll({
where: { user_id: userId },
include: [ { model: Project, attributes: ["id", "name"] }, { model: Role, attributes: ["id", "name"] } ],
});
res.json(assignments);
} catch (error) { next(error); }
});
r.delete('/roles/:role_id/permissions/:permission_id',
requirePerm(PERM.rbac_admin.grant_perm, { scope: 'global' }),
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 });
}
);
router.post("/user-project-roles", requirePerm("users.manage"), async (req, res, next) => {
const { userId, projectId, roleId } = req.body;
if (!userId || !projectId || !roleId) return res.status(400).json({ message: "userId, projectId, and roleId are required." });
try {
const [assignment, created] = await UserProjectRole.findOrCreate({
where: { user_id: userId, project_id: projectId, role_id: roleId },
defaults: { user_id: userId, project_id: projectId, role_id: roleId },
});
if (!created) return res.status(409).json({ message: "This assignment already exists." });
res.status(201).json(assignment);
} catch (error) { next(error); }
});
/** LIST: user→roles(+scope) */
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);
}
);
router.delete("/user-project-roles", requirePerm("users.manage"), async (req, res, next) => {
const { userId, projectId, roleId } = req.body;
if (!userId || !projectId || !roleId) return res.status(400).json({ message: "userId, projectId, and roleId are required." });
try {
const deletedCount = await UserProjectRole.destroy({ where: { user_id: userId, project_id: projectId, role_id: roleId } });
if (deletedCount === 0) return res.status(404).json({ message: 'Assignment not found.' });
res.status(204).send();
} catch (error) { next(error); }
});
/** 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;
export default router;

View File

@@ -1,35 +1,91 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth.js';
import { requirePermission } from '../middleware/perm.js';
import { callProc } from '../db/index.js';
// FILE: backend/src/routes/rfa.js
// RFA: create + update-status ผ่าน stored procedures
import { Router } from "express";
import sql, { callProc } from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const router = Router();
const r = Router();
router.post('/create',
requireAuth,
requirePermission(['RFA_CREATE'], { projectRequired: true }),
// CREATE (PROJECT scope) -> rfas.create
r.post(
"/create",
requirePerm("rfas.create", { projectParam: "project_id" }),
async (req, res, next) => {
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 json = JSON.stringify(item_doc_ids.map(Number));
await callProc('sp_rfa_create_with_items', [
req.user.user_id, project_id, cor_status_id, cor_no, title, originator_id, recipient_id, keywords, pdf_path, json, null
const {
project_id,
cor_status_id,
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 });
} catch (e) { next(e); }
} catch (e) {
next(e);
}
}
);
router.post('/update-status',
requireAuth,
requirePermission(['RFA_STATUS_UPDATE'], { projectRequired: true }),
// UPDATE STATUS (PROJECT scope) -> rfas.respond
r.post(
"/update-status",
requirePerm("rfas.respond"),
async (req, res, next) => {
try {
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 });
} catch (e) { next(e); }
} catch (e) {
next(e);
}
}
);
export default router;
export default r;

View File

@@ -1,206 +1,167 @@
// backend/src/routes/rfas.js (merged)
// Base: rfas.js + enhanced list/sort/paging/overdue from rfas-1.js
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'; // ถ้าไม่ได้ใช้ สามารถลบได้
// FILE: backend/src/routes/rfas.js
// RFAs list/get/create/update/delete — มาตรฐาน Bearer + requirePerm
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
const OWN = ownerResolvers(sql, 'rfas', 'id');
/* ----------------------------- Utilities ----------------------------- */
// Allow-list สำหรับการ sort ป้องกัน SQL injection
const ALLOWED_SORT = new Map([
['updated_at', 'updated_at'],
['due_date', 'due_date'],
['created_at', 'created_at'],
['id', 'id']
["updated_at", "updated_at"],
["due_date", "due_date"],
["created_at", "created_at"],
["id", "id"],
]);
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';
function parseSort(sort = "updated_at:desc") {
const [c, d] = String(sort).split(":");
const col = ALLOWED_SORT.get(c) || "updated_at";
const dir = (d || "desc").toLowerCase() === "asc" ? "ASC" : "DESC";
return `\`${col}\` ${dir}`;
}
function parsePaging({ page = 1, pageSize = 20 }) {
function paging({ 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 };
}
// ตัวกรองเพิ่มเติม (จาก rfas-1.js) + ผสมกับเงื่อนไข scope เดิม
function buildExtraFilters({ q, status, overdue, project_id, org_id }) {
const parts = [];
const params = {};
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' }),
// LIST (PROJECT scope enforced: filter ด้วย principal)
r.get(
"/",
requirePerm("rfas.view", { projectParam: "project_id" }),
async (req, res) => {
try {
const { q, status, overdue, sort, page, pageSize, project_id, org_id } = req.query;
const { q, status, overdue, sort, page, pageSize, project_id } = req.query;
const orderBy = parseSort(sort);
const { limit, offset, page: p, pageSize: ps } = parsePaging({ page, pageSize });
const { limit, offset, page: p, pageSize: ps } = paging({ page, pageSize });
// base scope จาก principal (org/project)
const base = buildScopeWhere(req.principal, {
tableAlias: 'r', orgColumn: 'r.org_id', projectColumn: 'r.project_id',
permCode: 'rfa.read', preferProject: true,
});
const P = req.principal;
const cond = [];
const params = [];
// extra filters
const extra = buildExtraFilters({ q, status, overdue, project_id, org_id });
if (!P.is_superadmin) {
if (project_id) {
const pid = Number(project_id);
if (!P.inProject(pid))
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
cond.push("r.project_id=?");
params.push(pid);
} else if (P.project_ids?.length) {
cond.push(
`r.project_id IN (${P.project_ids.map(() => "?").join(",")})`
);
params.push(...P.project_ids);
}
} else if (project_id) {
cond.push("r.project_id=?");
params.push(Number(project_id));
}
// รวม where
const where = [base.where, extra.where].filter(Boolean).join(' AND ') || '1=1';
const params = { ...base.params, ...extra.params, limit, offset };
if (status) {
cond.push("r.status=?");
params.push(status);
}
if (q) {
cond.push("(r.rfa_no LIKE ? OR r.title LIKE ? OR r.code LIKE ?)");
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
}
if (String(overdue) === "1") {
cond.push(
"r.due_date IS NOT NULL AND r.due_date < CURDATE() AND r.status NOT IN ('Closed','Approved')"
);
}
const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
// total
const [[{ cnt: total }]] = await sql.query(
`SELECT COUNT(*) AS cnt FROM rfas r WHERE ${where}`,
const [[{ cnt }]] = await sql.query(
`SELECT COUNT(*) AS cnt FROM rfas r ${where}`,
params
);
// 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
`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(total || 0), page: p, pageSize: ps });
} catch (e) {
res.status(500).json({ error: e.message || 'rfas/list failed' });
}
res.json({ data: rows, total: Number(cnt || 0), page: p, pageSize: ps });
}
);
/* ------------------------------- GET ONE ------------------------------
// ยึดรูปแบบตรวจสิทธิ์จาก rfas.js
------------------------------------------------------------------------*/
r.get('/:id',
requirePerm('rfa.read', { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
try {
// GET ONE
r.get("/:id", requirePerm("rfas.view"), 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 [[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);
} 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 }),
// CREATE
r.post(
"/",
requirePerm("rfas.create", { projectParam: "project_id" }),
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';
if (!project_id || !title)
return res.status(400).json({ error: "project_id and title required" });
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]
[
org_id ?? null,
project_id,
rfa_no ?? null,
title,
status ?? "draft",
req.principal.user_id,
]
);
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 {
// UPDATE (respond/edit)
r.patch("/:id", requirePerm("rfas.respond"), async (req, res) => {
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' });
}
}
);
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" });
// 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 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" });
if (Object.keys(patch).length === 0) {
return res.status(400).json({ error: 'no fields to update' });
}
if ('status' in patch) {
const s = String(patch.status);
const ok = ['draft','submitted','Pending','Review','Approved','Closed'].includes(s);
if (!ok) return res.status(400).json({ error: 'invalid status' });
}
const sets = Object.keys(patch).map(k => `\`${k}\` = :${k}`);
patch.id = id;
const sets = Object.keys(patch).map((k) => `\`${k}\`=?`);
await sql.query(
`UPDATE rfas SET ${sets.join(', ')}, updated_at=NOW() WHERE id=:id`,
patch
`UPDATE rfas SET ${sets.join(", ")}, updated_at=NOW() WHERE id=?`,
[...Object.values(patch), id]
);
res.json({ ok: 1, id });
} catch (e) {
res.status(500).json({ error: e.message || 'rfas/patch failed' });
}
}
);
});
/* ------------------------------- DELETE ------------------------------- */
r.delete('/:id',
requirePerm('rfa.delete', { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
try {
// DELETE
r.delete("/:id", requirePerm("rfas.delete"), async (req, res) => {
const id = Number(req.params.id);
await sql.query('DELETE FROM rfas WHERE id=?', [id]);
res.json({ ok: 1, id });
} catch (e) {
res.status(500).json({ error: e.message || 'rfas/delete failed' });
}
}
);
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;

View File

@@ -1,48 +1,95 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth.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 SubCatModel from '../db/models/SubCategory.js';
// FILE: backend/src/routes/subcategories.js
// Master data: subcategories — GLOBAL read/write (ตาม categories.js)
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
const SubCat = SubCatModel(sequelize);
r.get('/sub_categories', requireAuth, async (req, res) => {
const { q, project_id, page=1, page_size=50 } = req.query;
const limit = Math.min(Number(page_size)||50, 200);
const offset = (Math.max(Number(page)||1,1)-1) * limit;
const where = {};
if (project_id) where.project_id = project_id;
if (q) where.sub_cat_name = sequelize.where(sequelize.fn('LOWER', sequelize.col('sub_cat_name')), 'LIKE', `%${String(q).toLowerCase()}%`);
const { rows, count } = await SubCat.findAndCountAll({ where, limit, offset, order:[['sub_cat_name','ASC']] });
res.json({ items: rows, total: count, page: Number(page), page_size: limit });
// LIST (GLOBAL read)
r.get(
"/sub_categories",
requirePerm("organizations.view"),
async (req, res) => {
const { q, cat_id, page = 1, page_size = 50 } = req.query;
const limit = Math.min(Number(page_size) || 50, 200);
const offset = (Math.max(Number(page) || 1, 1) - 1) * 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) => {
const { project_id, sub_cat_name, parent_cat_id, code } = req.body || {};
if (!project_id || !sub_cat_name) return res.status(400).json({ error: 'project_id and sub_cat_name required' });
const created = await SubCat.create({ project_id, sub_cat_name, parent_cat_id, code });
res.status(201).json({ sub_cat_id: created.sub_cat_id });
});
r.patch('/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' });
const { sub_cat_name, parent_cat_id, code } = req.body || {};
if (sub_cat_name !== undefined) row.sub_cat_name = sub_cat_name;
if (parent_cat_id !== undefined) row.parent_cat_id = parent_cat_id;
if (code !== undefined) row.code = code;
await row.save();
// UPDATE
r.patch(
"/sub_categories/:id",
requirePerm("settings.manage"),
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.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();
// DELETE
r.delete(
"/sub_categories/:id",
requirePerm("settings.manage"),
async (req, res) => {
const id = Number(req.params.id);
await sql.query("DELETE FROM subcategories WHERE sub_cat_id=?", [id]);
res.json({ ok: true });
});
}
);
export default r;

View File

@@ -1,190 +1,124 @@
// src/routes/technicaldocs.js (ESM)
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';
// FILE: backend/src/routes/technicaldocs.js
// แมปเป็นเอกสารประเภทหนึ่ง → ใช้สิทธิ์ documents.view/manage (PROJECT)
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
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
r.get('/',
requirePerm(PERM.transmittal.read, { scope: 'global' }),
r.get(
"/",
requirePerm("documents.view", { projectParam: "project_id" }),
async (req, res) => {
const { project_id, org_id, tr_no, q, limit = 50, offset = 0 } = req.query;
const base = buildScopeWhere(req.principal, {
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 { project_id, status, q, limit = 50, offset = 0 } = req.query;
const P = req.principal;
const cond = [];
const params = [];
// อนุญาตแก้ฟิลด์หลัก
const allow = ['tr_no','subject','status'];
for (const k of allow) {
if (k in req.body) {
fields.push(`${k} = ?`);
params.push(req.body[k]);
if (!P.is_superadmin) {
if (project_id) {
const pid = Number(project_id);
if (!P.inProject(pid))
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
cond.push("t.project_id=?");
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);
await sql.query(`UPDATE transmittals SET ${fields.join(', ')} WHERE id=?`, params);
res.json({ ok: 1 });
if (status) {
cond.push("t.status=?");
params.push(status);
}
);
// DELETE
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 });
if (q) {
cond.push("(t.doc_no LIKE ? OR t.title LIKE ?)");
params.push(`%${q}%`, `%${q}%`);
}
);
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 where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
const [rows] = await sql.query(
`SELECT t.* FROM technicaldocs t WHERE ${where}
ORDER BY t.id DESC LIMIT :limit OFFSET :offset`, params
`SELECT t.* FROM technicaldocs t ${where} ORDER BY t.id DESC LIMIT ? OFFSET ?`,
[...params, Number(limit), Number(offset)]
);
res.json(rows);
}
);
// GET
r.get('/:id',
requirePerm(PERM.technicaldoc.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
r.get("/:id", requirePerm("documents.view"), async (req, res) => {
const id = Number(req.params.id);
const [[row]] = await sql.query('SELECT * FROM technicaldocs WHERE id=?', [id]);
if (!row) return res.status(404).json({ error: 'Not found' });
const [[row]] = await sql.query("SELECT * FROM technicaldocs 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(PERM.technicaldoc.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
r.post(
"/",
requirePerm("documents.manage", { projectParam: "project_id" }),
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(
`INSERT INTO technicaldocs (org_id, project_id, doc_no, title, status, created_by)
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
r.put('/:id',
requirePerm(PERM.technicaldoc.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
r.put("/:id", requirePerm("documents.manage"), async (req, res) => {
const id = Number(req.params.id);
const { title, status } = req.body;
await sql.query('UPDATE technicaldocs SET title=?, status=? WHERE id=?', [title, status, id]);
const [[row]] = await sql.query("SELECT * FROM technicaldocs 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 { 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
r.delete('/:id',
requirePerm(PERM.technicaldoc.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
r.delete("/:id", requirePerm("documents.manage"), async (req, res) => {
const id = Number(req.params.id);
await sql.query('DELETE FROM technicaldocs WHERE id=?', [id]);
const [[row]] = await sql.query("SELECT * FROM technicaldocs 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 technicaldocs WHERE id=?", [id]);
res.json({ ok: 1 });
}
);
});
export default r;

View File

@@ -1,204 +1,131 @@
// backend/src/routes/transmittals.js (merged)
// Base: transmittals.js + list/sort/paging from transmittals-1.js
// Notes:
// - ยึด RBAC/Scope/Permissions แบบเดิมของ transmittals.js
// - Faceted list -> ส่ง meta { data, total, page, pageSize }
// - 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';
// FILE: backend/src/routes/transmittals.js
// ทั้งโมดูลใช้สิทธิ์เดียว: transmittals.manage (PROJECT)
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
const OWN = ownerResolvers(sql, 'transmittals', 'id');
/* ----------------------------- Utilities ----------------------------- */
// จำกัดฟิลด์ที่อนุญาตให้ sort เพื่อลดความเสี่ยง SQLi
const ALLOWED_SORT = new Map([
['updated_at', 'updated_at'],
['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 };
}
// LIST
r.get(
"/",
requirePerm("transmittals.manage", { projectParam: "project_id" }),
async (req, res) => {
const { project_id, tr_no, q, limit = 50, offset = 0 } = req.query;
const P = req.principal;
const cond = [];
const params = [];
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 (!P.is_superadmin) {
if (project_id) {
const pid = Number(project_id);
if (!P.inProject(pid))
return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
cond.push("t.project_id=?");
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 (tr_no) {
cond.push("t.tr_no=?");
params.push(tr_no);
}
if (q) {
// ใช้ฟิลด์พื้นฐานที่ transmittals.js มีแน่นอน (tr_no, subject)
extra.push('(t.tr_no LIKE :q OR t.subject LIKE :q)');
params.q = `%${q}%`;
cond.push("(t.tr_no LIKE ? OR t.subject LIKE ?)");
params.push(`%${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) => {
try {
const { project_id, org_id, tr_no, q, sort, page, pageSize } = req.query;
const orderBy = parseSort(sort);
const { limit, offset, page: p, pageSize: ps } = parsePaging({ page, pageSize });
const base = buildScopeWhere(req.principal, {
tableAlias: 't',
orgColumn: 't.org_id',
projectColumn: 't.project_id',
permCode: PERM.transmittal.read,
preferProject: true
});
const extra = buildExtraFilters({ project_id, org_id, tr_no, q });
const where = [base.where, extra.where].filter(Boolean).join(' AND ') || '1=1';
const params = { ...base.params, ...extra.params, limit, offset };
// total
const [[{ cnt: total }]] = await sql.query(
`SELECT COUNT(*) AS cnt FROM transmittals t WHERE ${where}`,
params
);
// rows
const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
const [rows] = await sql.query(
`SELECT t.*
FROM transmittals t
WHERE ${where}
ORDER BY ${orderBy}
LIMIT :limit OFFSET :offset`,
params
`SELECT t.* FROM transmittals t ${where} ORDER BY t.id DESC LIMIT ? OFFSET ?`,
[...params, Number(limit), Number(offset)]
);
res.json({ data: rows, total: Number(total || 0), page: p, pageSize: ps });
} catch (e) {
res.status(500).json({ error: e.message || 'transmittals/list failed' });
}
res.json(rows);
}
);
/* ------------------------------- GET ONE ------------------------------ */
r.get('/:id',
requirePerm(PERM.transmittal.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
try {
// GET
r.get("/: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 [[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);
} 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 }),
// CREATE
r.post(
"/",
requirePerm("transmittals.manage", { projectParam: "project_id" }),
async (req, res) => {
try {
// ยึดสคีมาหลักจาก transmittals.js
const { org_id, project_id, tr_no, subject, status } = req.body;
const { org_id, project_id, tr_no, subject, status } = req.body || {};
if (!project_id || !tr_no)
return res.status(400).json({ error: "project_id and tr_no required" });
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]
[
org_id ?? null,
project_id,
tr_no,
subject ?? null,
status ?? null,
req.principal.user_id,
]
);
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 {
// UPDATE
r.patch("/:id", requirePerm("transmittals.manage"), async (req, res) => {
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 [[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" });
if (Object.keys(patch).length === 0) {
return res.status(400).json({ error: 'no fields to update' });
}
if ('status' in patch) {
const s = String(patch.status);
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' });
}
}
);
/* -------------------------------- DELETE ------------------------------ */
r.delete('/:id',
requirePerm(PERM.transmittal.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
try {
const id = Number(req.params.id);
await sql.query('DELETE FROM transmittals WHERE id=?', [id]);
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 });
} catch (e) {
res.status(500).json({ error: e.message || 'transmittals/delete failed' });
}
}
);
});
// 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;

View File

@@ -1,67 +1,108 @@
import { Router } from 'express';
import multer from 'multer';
import fs from 'node:fs';
import path from 'node:path';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import PERM from '../config/permissions.js';
// FILE: backend/src/routes/uploads.js
// อัปโหลดไฟล์ผูกกับโมดูล (PROJECT scope): documents/drawings/correspondences/rfas/transmittals
import { Router } from "express";
import multer from "multer";
import fs from "node:fs";
import path from "node:path";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
const UPLOAD_BASE = process.env.UPLOAD_BASE || '/share/dms-data';
function ensureDir(p) { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }
const UPLOAD_BASE = process.env.UPLOAD_BASE || "/share/dms-data";
const ensureDir = (p) => {
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
};
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
try {
const { module, id } = req.params;
const [[row]] = await sql.query(`SELECT org_id, project_id, created_at FROM ${module} WHERE id=?`, [Number(id)]);
if (!row) return cb(new Error('Resource not found'));
const dt = new Date(row.created_at || Date.now());
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}`);
async function fetchRef(module, id) {
const tbl = String(module);
const idCol = "id";
const [[row]] = await sql.query(
`SELECT org_id, project_id, created_at FROM ${tbl} WHERE ${idCol}=?`,
[Number(id)]
);
return row || null;
}
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 PERM_UPLOAD = {
correspondences: PERM.correspondence.upload,
rfas: PERM.rfa.upload,
drawings: PERM.drawing.upload,
transmittals: PERM.transmittal?.upload,
};
async function getProjectIdByModule(req){
const { module, id } = req.params;
const [[row]] = await sql.query(`SELECT project_id FROM ${module} WHERE id=?`, [Number(id)]);
return row?.project_id ?? null;
// map module -> permission
function uploadPerm(module) {
if (module === "documents") return "documents.manage";
if (module === "drawings") return "drawings.upload";
if (module === "correspondences") return "corr.manage";
if (module === "rfas") return "rfas.respond";
if (module === "transmittals") return "transmittals.manage";
return null;
}
async function refProjectId(module, id) {
const [[row]] = await sql.query(
`SELECT project_id FROM ${module} WHERE id=?`,
[Number(id)]
);
return row?.project_id ?? null;
}
r.post('/:module/:id/file',
(req, res, next) => {
const perm = PERM_UPLOAD[req.params.module];
if (!perm) return res.status(400).json({ error: 'Unsupported module' });
return requirePerm(perm, { scope: 'project', getProjectId: getProjectIdByModule })(req, res, 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 });
}
r.post(
"/:module/:id/file",
(req, res, next) => {
const perm = uploadPerm(req.params.module);
if (!perm) return res.status(400).json({ error: "Unsupported module" });
return requirePerm(perm, { projectParam: undefined })(req, res, next);
},
async (req, res, next) => {
// ABAC: ตรวจ project scope ของ record
const pid = await refProjectId(req.params.module, req.params.id);
if (
!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;

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

63
backend/src/routes/users.js Normal file → Executable file
View File

@@ -1,30 +1,53 @@
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import PERM from '../config/permissions.js';
// 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 [[u]] = await sql.query('SELECT user_id, username, email, first_name, last_name FROM users WHERE user_id=?',
[req.principal.userId]);
if (!u) return res.status(404).json({ error: 'User not found' });
// roles in plain
const [roles] = await sql.query(`
SELECT r.role_code, r.role_name, ur.org_id, ur.project_id
// 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=?`, [req.principal.userId]);
res.json({ ...u, roles, role_codes: [...req.principal.roleCodes] });
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,
});
});
// (optional) USERS LIST ให้เฉพาะ SUPER_ADMIN หรือ ADMIN (ใน org ตัวเอง)
r.get('/',
requirePerm('user.read', { scope: 'global' }),
// USERS LIST (ORG scope) — admin.access
r.get(
"/",
requirePerm("admin.access", { orgParam: "org_id" }),
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);
}
);

View File

@@ -1,55 +1,39 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth.js';
import { requireRole } from '../middleware/rbac.js';
import { User } from '../db/sequelize.js';
import { hashPassword } from '../utils/passwords.js';
import { sequelize } from '../db/sequelize.js';
import UPRModel from '../db/models/UserProjectRole.js';
import ProjectModel from '../db/models/Project.js';
// FILE: backend/src/routes/users_extras.js
// NOTE: ของเดิมใช้ cookie + Sequelize -> ปรับให้อยู่หลัง Bearer stack และจำกัดความสามารถ
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
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) => {
const targetId = Number(req.params.id);
const isSelf = req.user?.user_id === targetId;
const isAdmin = (req.user?.roles || []).includes('Admin');
if (!isSelf && !isAdmin) return res.status(403).json({ error: 'Forbidden' });
/**
* PATCH /users/:id/password
* เฉพาะผู้มี settings.manage (GLOBAL) — (คำเตือน: ต้องมีระบบ hash/rotate ที่ service auth)
*/
r.patch(
"/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' });
const row = await User.findByPk(targetId);
if (!row) return res.status(404).json({ error: 'Not found' });
row.password_hash = await hashPassword(new_password);
await row.save();
res.json({ ok: true });
});
// 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'] });
/**
* GET /users/me/projects — สรุปโปรเจ็ค/บทบาทของผู้ใช้
*/
r.get("/users/me/projects", async (req, res) => {
const uid = req.principal.user_id;
const [rows] = await sql.query(
`SELECT upr.project_id, r.role_code, r.role_name
FROM user_project_roles upr
JOIN roles r ON r.role_id = upr.role_id
WHERE upr.user_id=? ORDER BY upr.project_id`,
[uid]
);
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;

View File

@@ -1,100 +1,100 @@
// src/routes/view.js
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';
// FILE: backend/src/routes/view.js
// Saved Views: อ่านด้วย reports.view (GLOBAL); เขียนด้วย settings.manage
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
const OWN = ownerResolvers(sql, 'saved_views', 'id');
// LIST: GET /api/view?project_id=&org_id=&shared=1
r.get('/',
requirePerm(PERM.savedview.read, { scope: 'global' }),
async (req, res) => {
const { project_id, org_id, shared, q, limit = 50, offset = 0 } = req.query;
const base = buildScopeWhere(req.principal, {
tableAlias: 'v',
orgColumn: 'v.org_id',
projectColumn: 'v.project_id',
permCode: PERM.savedview.read,
preferProject: true,
});
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 ') : ''})`;
// LIST (ทุกคนที่มี reports.view)
r.get("/", requirePerm("reports.view"), async (req, res) => {
const { project_id, shared = "1", q, limit = 50, offset = 0 } = req.query;
const p = req.principal;
const cond = [];
const params = [];
// ให้เห็นของตัวเองเสมอ + shared
cond.push("(v.is_shared=1 OR v.owner_user_id=?)");
params.push(p.user_id);
if (project_id) {
cond.push("v.project_id=?");
params.push(Number(project_id));
}
if (q) {
cond.push("v.name LIKE ?");
params.push(`%${q}%`);
}
if (shared === "0") {
cond.push("v.is_shared=0");
}
const where = `WHERE ${cond.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
`SELECT v.* FROM saved_views v ${where} ORDER BY v.id DESC LIMIT ? OFFSET ?`,
[...params, Number(limit), Number(offset)]
);
res.json(rows);
}
);
});
// GET by id
r.get('/:id',
requirePerm(PERM.savedview.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
// GET
r.get("/:id", requirePerm("reports.view"), 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);
const [[row]] = await sql.query("SELECT * FROM saved_views WHERE id=?", [id]);
if (!row) return res.status(404).json({ error: "Not found" });
if (
!(
row.is_shared ||
row.owner_user_id === req.principal.user_id ||
req.principal.is_superadmin
)
) {
return res.status(403).json({ error: "FORBIDDEN" });
}
);
res.json(row);
});
// 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;
// CREATE / UPDATE / DELETE (ต้องมี settings.manage)
r.post("/", requirePerm("settings.manage"), 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]
[
org_id ?? null,
project_id ?? null,
name ?? "",
JSON.stringify(payload_json ?? {}),
Number(is_shared) ? 1 : 0,
req.principal.user_id,
]
);
res.json({ id: rs.insertId });
}
);
res.status(201).json({ id: rs.insertId });
});
// UPDATE (เฉพาะใน scope และถ้าเป็นของตนเอง หรือเป็นแอดมินตามนโยบาย)
r.put('/:id',
requirePerm(PERM.savedview.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
r.put("/:id", requirePerm("settings.manage"), async (req, res) => {
const id = Number(req.params.id);
const { name, payload_json, is_shared } = req.body;
// ตรวจ owner ถ้าต้องการบังคับเป็นของตัวเอง (option)
const [[sv]] = await sql.query('SELECT owner_user_id FROM saved_views WHERE id=?', [id]);
if (!sv) return res.status(404).json({ error: 'Not found' });
// ถ้าจะจำกัดเฉพาะเจ้าของ: if (sv.owner_user_id !== req.principal.userId && !req.principal.isSuperAdmin) return res.status(403).json({ error: 'NOT_OWNER' });
const { name, payload_json, is_shared } = req.body || {};
await sql.query(
'UPDATE saved_views SET name=?, payload_json=?, is_shared=? WHERE id=?',
[name, JSON.stringify(payload_json ?? {}), Number(is_shared) ? 1 : 0, id]
"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 });
}
);
});
// DELETE
r.delete('/:id',
requirePerm(PERM.savedview.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
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]);
await sql.query("DELETE FROM saved_views WHERE id=?", [id]);
res.json({ ok: 1 });
}
);
});
export default r;

View File

@@ -1,29 +1,23 @@
// src/routes/views.js (ESM)
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import PERM from '../config/permissions.js';
// FILE: backend/src/routes/views.js
// จำกัดเฉพาะแอดมินระบบ: settings.manage
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
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(PERM.viewdef.read, { scope: 'global' }),
async (req, res) => {
r.get("/", requirePerm("settings.manage"), async (_req, res) => {
const [rows] = await sql.query(
`SELECT TABLE_SCHEMA AS db, TABLE_NAME AS view_name
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);
}
);
});
// GET view definition
r.get('/:view_name',
requirePerm(PERM.viewdef.read, { scope: 'global' }),
async (req, res) => {
r.get("/:view_name", requirePerm("settings.manage"), async (req, res) => {
const viewName = req.params.view_name;
const [[row]] = await sql.query(
`SELECT VIEW_DEFINITION AS definition
@@ -31,9 +25,8 @@ r.get('/:view_name',
WHERE TABLE_SCHEMA=? AND TABLE_NAME=?`,
[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 });
}
);
});
export default r;

View File

@@ -1,50 +1,55 @@
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import PERM from '../config/permissions.js';
// FILE: backend/src/routes/volumes.js
// Master data: volumes
// - Read: organizations.view (GLOBAL)
// - Write: settings.manage (GLOBAL)
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
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('/',
requirePerm(PERM.volume.read, { scope: 'global' }),
async (req, res) => {
const [rows] = await sql.query('SELECT * FROM volumes ORDER BY volume_id DESC');
res.json(rows);
}
);
// CREATE
r.post("/", requirePerm("settings.manage"), async (req, res) => {
const { volume_code, volume_name } = req.body || {};
if (!volume_code || !volume_name) {
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('/',
requirePerm(PERM.volume.create, { scope: 'global' }),
async (req, res) => {
const { volume_code, volume_name } = req.body;
const [rs] = await sql.query('INSERT INTO volumes (volume_code, volume_name) VALUES (?,?)', [volume_code, volume_name]);
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 });
}
);
// DELETE
r.delete("/:id", requirePerm("settings.manage"), async (req, res) => {
const id = Number(req.params.id);
await sql.query("DELETE FROM volumes WHERE volume_id=?", [id]);
res.json({ ok: true });
});
export default r;

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

View File

@@ -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) {
const saltRounds = 10;
return bcrypt.hash(plain, saltRounds);

View File

@@ -1,45 +1,63 @@
// src/utils/rbac.js
import sql from '../db/index.js';
// FILE: backend/src/utils/rbac.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 (บทบาท, องค์กร, โปรเจกต์) ให้ผู้ใช้
*/
export async function loadPrincipal(userId) {
const [rolesRows] = await sql.query(/*sql*/`
SELECT ur.user_id, r.role_code, ur.org_id, ur.project_id
FROM user_roles ur
JOIN roles r ON r.role_id = ur.role_id
WHERE ur.user_id = ?
`, [userId]);
const [rolesRows] = await sql.query(
/*sql*/ `
SELECT ur.user_id, r.role_code, ur.org_id, ur.project_id
FROM user_roles ur
JOIN roles r ON r.role_id = ur.role_id
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*/`
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 roleCodes = new Set(rolesRows.map((r) => r.role_code));
const isSuperAdmin = roleCodes.has("SUPER_ADMIN");
// 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));
const isSuperAdmin = roleCodes.has('SUPER_ADMIN');
// 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);
}
// 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));
// 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 {
return {
userId,
roleCodes, // Set<role_code>
isSuperAdmin, // SUPER_ADMIN = true
@@ -55,30 +73,35 @@ return {
* - ADMIN: ผ่านได้ "ภายใน org ของตัวเอง" เท่านั้น
* - อื่น ๆ: ต้องถือ permission_code และเข้า scope ที่ถูกต้อง
*/
export function canPerform(principal, permCode, { scope = 'global', orgId = null, projectId = null } = {}) {
if (!principal) return false;
if (principal.isSuperAdmin) return true;
const hasAdminRole = principal.roleCodes.has('ADMIN');
export function canPerform(
principal,
permCode,
{ 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') {
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;
return false;
}

View File

@@ -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
* - SUPER_ADMIN: ไม่จำกัด
@@ -12,17 +23,18 @@
* permCode: permission_code ที่ใช้สำหรับ read list (เช่น 'correspondence.read')
* preferProject: true -> บังคับต้อง match project scope ก่อน (ถ้ามี)
*/
export function buildScopeWhere(principal, {
tableAlias, orgColumn, projectColumn, permCode, preferProject = false,
}) {
if (principal.isSuperAdmin) return { where: '1=1', params: {} };
export function buildScopeWhere(
principal,
{ tableAlias, orgColumn, projectColumn, permCode, preferProject = false }
) {
if (principal.isSuperAdmin) return { where: "1=1", params: {} };
const perm = principal.perms.get(permCode);
const orgIds = new Set(principal.orgIds);
const projectIds = new Set(principal.projectIds);
// กรณี ADMIN: ให้ดูภายใน org/project ตัวเองได้ทั้งหมด (แต่ต้องถือ permCode)
if (principal.roleCodes.has('ADMIN') && perm) {
if (principal.roleCodes.has("ADMIN") && perm) {
const orgList = [...orgIds];
const prjList = [...projectIds];
if (preferProject && prjList.length > 0) {
@@ -38,11 +50,11 @@ export function buildScopeWhere(principal, {
};
}
// ถ้าไม่มี mapping เลย ให้ไม่เห็นอะไร
return { where: '1=0', params: {} };
return { where: "1=0", params: {} };
}
// บทบาทอื่น: อิงตาม perm scope
if (!perm) return { where: '1=0', params: {} };
if (!perm) return { where: "1=0", params: {} };
const permOrg = [...perm.orgIds];
const permPrj = [...perm.projectIds];
@@ -55,25 +67,31 @@ export function buildScopeWhere(principal, {
}
// ถ้า perm ไม่มี scope ผูก (global grant) ให้ผ่านทั้งหมด
return { where: '1=1', params: {} };
return { where: "1=1", params: {} };
}
/**
* owner resolvers: อ่าน org_id/project_id จาก DB ด้วย id
* ใช้กับ requirePerm(getOrgId/getProjectId) เสมอ
*/
export function ownerResolvers(sql, mainTable, idColumn = 'id') {
export function ownerResolvers(sql, mainTable, idColumn = "id") {
return {
async getOrgIdById(req) {
const id = Number(req.params.id ?? req.body?.id);
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;
},
async getProjectIdById(req) {
const id = Number(req.params.id ?? req.body?.id);
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;
},
};

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

View File

@@ -1,8 +0,0 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}

2
docker-backend-build.yml Executable file → Normal file
View File

@@ -16,7 +16,7 @@ services:
target: prod
image: dms-backend:prod
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 บน local
# cd backend

74
docker-compose.yml Executable file → Normal file
View File

@@ -1,4 +1,4 @@
# DMS Container v0_6_0
# DMS Container v0_7_0
# version: "3.8"
x-restart: &restart_policy
restart: unless-stopped
@@ -25,10 +25,10 @@ services:
deploy:
resources:
limits:
cpus: '2.0'
cpus: "2.0"
memory: 4G
reservations:
cpus: '0.5'
cpus: "0.5"
memory: 1G
environment:
MYSQL_ROOT_PASSWORD: "Center#2025"
@@ -45,7 +45,8 @@ services:
- "/share/Container/dms/mariadb/init:/docker-entrypoint-initdb.d:ro"
- "/share/dms-data/mariadb/backup:/backup"
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -pCenter#2025 || exit 1"]
test:
["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -pCenter#2025 || exit 1"]
interval: 10s
timeout: 5s
retries: 15
@@ -56,16 +57,16 @@ services:
container_name: dms_backend
stdin_open: true
tty: true
user: "node"
# user: "1000:1000"
#user: "node"
user: "1000:1000"
working_dir: /app
deploy:
resources:
limits:
cpus: '2.0'
cpus: "2.0"
memory: 1G
reservations:
cpus: '0.25'
cpus: "0.25"
memory: 256M
environment:
TZ: "Asia/Bangkok"
@@ -80,11 +81,16 @@ services:
DB_USER: "center"
DB_PASSWORD: "Center#2025"
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"
PASSWORD_SALT_ROUNDS: "10"
FRONTEND_ORIGIN: "https://lcbp3.mycloudnas.com"
CORS_ORIGINS: "https://backend.np-dms.work,http://localhost:3000,http://127.0.0.1:3000"
FRONTEND_ORIGIN: "https://lcbp3.np-dms.work"
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_MAX: "200"
BACKEND_LOG_DIR: "/app/logs"
@@ -114,22 +120,27 @@ services:
container_name: dms_frontend
stdin_open: true
tty: true
user: "node"
# user: "1000:1000"
# user: "node"
user: "1000:1000"
working_dir: /app
deploy:
resources:
limits:
cpus: '1.0'
memory: 1G
cpus: "2.0"
memory: 2G
environment:
TZ: "Asia/Bangkok"
NODE_ENV: "development"
NEXT_PUBLIC_API_BASE: "/api"
# NEXT_PUBLIC_API_BASE: "/api"
CHOKIDAR_USEPOLLING: "1"
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"
INTERNAL_API_BASE: "http://backend:3001"
JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca"
JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c"
expose:
- "3000"
networks: [dmsnet]
@@ -144,7 +155,11 @@ services:
backend:
condition: service_healthy
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
timeout: 5s
retries: 30
@@ -157,7 +172,7 @@ services:
deploy:
resources:
limits:
cpus: '0.25'
cpus: "0.25"
memory: 256M
environment:
TZ: "Asia/Bangkok"
@@ -192,10 +207,10 @@ services:
deploy:
resources:
limits:
cpus: '1.0'
cpus: "1.0"
memory: 2G
reservations:
cpus: '0.5'
cpus: "0.5"
memory: 1G
environment:
POSTGRES_DB: "n8n"
@@ -218,7 +233,7 @@ services:
deploy:
resources:
limits:
cpus: '0.25'
cpus: "0.25"
memory: 256M
environment:
PGADMIN_DEFAULT_EMAIL: "center.pslcp3@gmail.com"
@@ -246,22 +261,23 @@ services:
deploy:
resources:
limits:
cpus: '1.5'
cpus: "1.5"
memory: 2G
reservations:
cpus: '0.25'
cpus: "0.25"
memory: 512M
environment:
TZ: "Asia/Bangkok"
NODE_ENV: "production"
N8N_PATH: "/n8n/"
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_PROTOCOL: "https"
N8N_HOST: "n8n.np-dms.work"
N8N_PORT: "5678"
N8N_PROXY_HOPS: "1"
N8N_DIAGNOSTICS_ENABLED: "false"
N8N_SECURE_COOKIE: "true"
N8N_ENCRYPTION_KEY: "9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI"
N8N_BASIC_AUTH_ACTIVE: "true"
@@ -300,7 +316,7 @@ services:
deploy:
resources:
limits:
cpus: '1.0' # 50% CPU
cpus: "1.0" # 50% CPU
memory: 512M
ports:
- "80:80" # HTTP
@@ -319,8 +335,8 @@ services:
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_healthy
# frontend:
# condition: service_healthy
phpmyadmin:
condition: service_started
n8n:
@@ -336,7 +352,7 @@ services:
deploy:
resources:
limits:
cpus: '0.50' # 50% CPU
cpus: "0.50" # 50% CPU
memory: 128M
networks: [dmsnet]
volumes:

26
docker-frontend-build.yml Executable file → Normal file
View File

@@ -1,31 +1,35 @@
services:
frontend_dev_image:
build:
# context: /share/Container/dms/frontend
context: ./frontend
context: /share/Container/dms/frontend
# context: ./frontend
dockerfile: Dockerfile
target: dev
args:
- NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
- NODE_ENV=development
image: dms-frontend:dev
command: ["true"]
frontend_prod_image:
build:
## context: /share/Container/dms/frontend
context: ./frontend
context: /share/Container/dms/frontend
# context: ./frontend
args:
- NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
- NODE_ENV=production #added
- NODE_ENV=production
dockerfile: Dockerfile
target: prod
## environment:
## - NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work
image: dms-frontend:prod
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
# **** สำหรับ 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
# cd frontend
# docker build -t dms-frontend:dev --target dev .

View File

@@ -1,7 +1,9 @@
node_modules
npm-debug.log
.next
.next/cache
.git
.gitignore
.DS_Store
logs
.env*.local
*.logs

9
frontend/.editorconfig Executable file
View 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
View 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
View 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