Compare commits
	
		
			57 Commits
		
	
	
		
			4cb7801fe8
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| fb26bb7b25 | |||
| c55f464f3c | |||
| aa799edf2c | |||
| cc47c6f5f1 | |||
|   | 15145260f9 | ||
| 360ab1ac12 | |||
|   | e58e164e54 | ||
| bbfbc5b910 | |||
| 670228b76e | |||
|   | 754e494e7f | ||
|   | 5dec188744 | ||
|   | 02e509986b | ||
|   | da568bb85f | ||
|   | 3448594bc5 | ||
|   | d2a7a3e478 | ||
|   | 72c2573648 | ||
|   | c98baa94fc | ||
|   | c414899a4f | ||
|   | 1ef1f8148f | ||
|   | 772239e708 | ||
|   | 7f41c35cb8 | ||
|   | d3844aec71 | ||
|   | 33022c1840 | ||
|   | a70ad11035 | ||
|   | 10150583cc | ||
|   | 4d7e69247d | ||
| eeb7808e29 | |||
|   | 03a8a3b864 | ||
|   | 6fea909902 | ||
|   | dd48a26196 | ||
|   | cb4146fa35 | ||
|   | 60880fb12e | ||
| d3339d75bf | |||
|   | a1e9600ad5 | ||
|   | a3d2e24861 | ||
|   | 2215633fb9 | ||
| bf3d9fc1d0 | |||
|   | 5cac3bdabf | ||
|   | 905afb56f5 | ||
| 5be0f5407b | |||
|   | 8b2dff8d1d | ||
| 83fc120885 | |||
|   | 60ea49ac4f | ||
|   | 1c710015de | ||
|   | 8d89e5f49a | ||
|   | cac84677fb | ||
|   | 709d18199c | ||
|   | b7260357af | ||
|   | b686855d82 | ||
|   | a337732d47 | ||
|   | 7dd5ce8015 | ||
|   | aca3667a9d | ||
|   | d8c604de1d | ||
|   | 5ce2b68155 | ||
|   | 82fc98e9df | ||
|   | 83a8cddc82 | ||
|   | db7030883f | 
							
								
								
									
										0
									
								
								.github/copilot-instructions.md
									
									
									
									
										vendored
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										0
									
								
								.github/copilot-instructions.md
									
									
									
									
										vendored
									
									
										
										
										Executable file → Normal file
									
								
							
							
								
								
									
										20
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										20
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										
										
										Executable file → Normal file
									
								
							| @@ -1,7 +1,16 @@ | ||||
| # ยกเว้นโฟลเดอร์ | ||||
| .devcontainer/ | ||||
| .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/ | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| [/dms] | ||||
| max_log = 361676 | ||||
| number = 5 | ||||
| max_log = 510381 | ||||
| number = 3 | ||||
| finish = 1 | ||||
|   | ||||
							
								
								
									
										21225
									
								
								.qsync/meta/qmeta0
									
									
									
									
									
								
							
							
						
						
									
										21225
									
								
								.qsync/meta/qmeta0
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										22597
									
								
								.qsync/meta/qmeta1
									
									
									
									
									
								
							
							
						
						
									
										22597
									
								
								.qsync/meta/qmeta1
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										19358
									
								
								.qsync/meta/qmeta2
									
									
									
									
									
								
							
							
						
						
									
										19358
									
								
								.qsync/meta/qmeta2
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										10997
									
								
								.qsync/meta/qmeta3
									
									
									
									
									
								
							
							
						
						
									
										10997
									
								
								.qsync/meta/qmeta3
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										1955
									
								
								.qsync/meta/qmeta4
									
									
									
									
									
								
							
							
						
						
									
										1955
									
								
								.qsync/meta/qmeta4
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										506
									
								
								Architech.md
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										506
									
								
								Architech.md
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,506 @@ | ||||
| # DMS Architecture Deep Dive (Backend + Frontend) | ||||
|  | ||||
| **Project:** Document Management System (DMS) — LCBP3 | ||||
| **Platform:** QNAP TS‑473A (Container Station) | ||||
| **Last updated:** 2025‑10‑07 (UTC+7) | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 0) TL;DR (Executive Summary) | ||||
|  | ||||
| * Reverse proxy (Nginx/NPM) เผยแพร่ Frontend (Next.js) และ Backend (Node.js/Express) ผ่าน HTTPS (HSTS) | ||||
| * Backend เชื่อม MariaDB 10.11 (ข้อมูลหลัก DMS) และแยก n8n + Postgres 16 สำหรับ workflow | ||||
| * RBAC/ABAC ถูกบังคับใช้งานใน middleware + มีชุด SQL (tables → triggers → procedures → views → seed) | ||||
| * ไฟล์จริง (PDF/DWG) เก็บนอก webroot ที่ **/share/dms‑data** พร้อมมาตรฐานการตั้งชื่อ+โฟลเดอร์ | ||||
| * Dev/Prod แยกชัดเจนผ่าน Docker multi‑stage + docker‑compose + โฟลเดอร์ persist logs/config/certs | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 1) Runtime Topology & Trust Boundaries | ||||
|  | ||||
| ``` | ||||
| Internet Clients (Browser) | ||||
|     │   HTTPS 443 (HSTS)  [QNAP mgmt = 8443] | ||||
|     ▼ | ||||
| ┌─────────────────────────────────────────────────────┐ | ||||
| │ Reverse Proxy Layer                                 │ | ||||
| │  ├─ Nginx (Alpine)  or  Nginx Proxy Manager (NPM)   │ | ||||
| │  ├─ TLS (LE cert; SAN multi‑subdomain)              │ | ||||
| │  └─ Routes:                                         │ | ||||
| │     • /, /_next/*   → Frontend (Next.js :3000)      │ | ||||
| │     • /api/*        → Backend (Express :3001)       │ | ||||
| │     • /pma/*        → phpMyAdmin                    │ | ||||
| │     • /n8n/*        → n8n (Workflows)               │ | ||||
| └─────────────────────────────────────────────────────┘ | ||||
|                │                         │ | ||||
|                │                         └──────────┐ | ||||
|                ▼                                    │ | ||||
|          Frontend (Next.js)                         │ | ||||
|                │  Cookie-based Auth (HttpOnly)      │ | ||||
|                ▼                                    ▼ | ||||
|          Backend (Node/Express ESM)  ─────────►  MariaDB 10.11 | ||||
|                │                                    │ | ||||
|                └────────────────────────────────────┘ | ||||
|                Project data (.pdf/.dwg) @ /share/dms-data | ||||
|  | ||||
|          n8n (workflows) ──► Postgres 16  (separate DB for automations) | ||||
| ``` | ||||
|  | ||||
| **Trust Boundaries** | ||||
|  | ||||
| * Public zone: Internet ↔ Reverse proxy | ||||
| * App zone: Reverse proxy ↔ FE/BE containers (internal Docker network) | ||||
| * Data zone: Backend ↔ Databases (MariaDB, Postgres) + `/share/dms-data` | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 2) Frontend Architecture (Next.js / React) | ||||
|  | ||||
| ### 2.1 Stack & Key libs | ||||
|  | ||||
| * **Next.js (App Router)**, **React**, ESM | ||||
| * **Tailwind CSS**, **PostCSS**, **shadcn/ui** (components.json) | ||||
| * Fetch API (credentials include) → Cookie Auth (HttpOnly) | ||||
|  | ||||
| ### 2.2 Directory Layout | ||||
|  | ||||
| ``` | ||||
| /frontend/ | ||||
| ├─ app/ | ||||
| │  ├─ login/ | ||||
| │  ├─ dashboard/ | ||||
| │  ├─ users/ | ||||
| │  ├─ correspondences/ | ||||
| │  ├─ health/ | ||||
| │  └─ layout.tsx / page.tsx (ตาม App Router) | ||||
| ├─ public/ | ||||
| ├─ Dockerfile (multi-stage: dev/prod) | ||||
| ├─ package.json | ||||
| ├─ next.config.js | ||||
| └─ ... | ||||
| ``` | ||||
|  | ||||
| ### 2.3 Routing & Layouts | ||||
|  | ||||
| * **Public**: `/login`, `/health` | ||||
| * **Protected**: `/dashboard`, `/users`, `/correspondences`, ... (client-side guard) | ||||
| * เก็บ **middleware.ts (ของเดิม)** เพื่อหลีกเลี่ยง regression; ใช้ client‑guard + server action อย่างระมัดระวัง | ||||
|  | ||||
| ### 2.4 Auth Flow (Cookie-based) | ||||
|  | ||||
| 1. ผู้ใช้ submit form `/login` → `POST /api/auth/login` (Backend) | ||||
| 2. Backend set **HttpOnly** cookie (JWT) + `SameSite=Lax/Strict`, `Secure` | ||||
| 3. หน้า protected เรียก `GET /api/auth/me` เพื่อตรวจสอบสถานะ | ||||
| 4. หาก 401 → redirect → `/login` | ||||
|  | ||||
| > **CORS/Fetch**: เปิด `credentials: 'include'` ทุกครั้ง, ตั้ง `NEXT_PUBLIC_API_BASE` เป็น origin ของ backend ผ่าน proxy (เช่น `https://lcbp3.np-dms.work`) | ||||
|  | ||||
| ### 2.5 UI/UX | ||||
|  | ||||
| * Sea‑blue palette, sidebar พับได้, card‑based KPI | ||||
| * ตารางข้อมูลเตรียมรองรับ **server‑side DataTables** | ||||
| * shadcn/ui: Button, Card, Badge, Tabs, Dropdown, Tooltip, Switch, etc. | ||||
|  | ||||
| ### 2.6 Config & ENV | ||||
|  | ||||
| * `NEXT_PUBLIC_API_BASE` (ex: `https://lcbp3.np-dms.work`) | ||||
| * Build output แยก dev/prod; ระวัง EACCES บน QNAP → ใช้ user `node` + ปรับสิทธิ์โวลุ่ม `.next/*` | ||||
|  | ||||
| ### 2.7 Error Handling & Observability (FE) | ||||
|  | ||||
| * Global error boundary (app router) + toast/alert patterns | ||||
| * Network layer: แยก handler สำหรับ 401/403/500 + retry/backoff ที่จำเป็น | ||||
| * Metrics (optional): web‑vitals, UX timing (เก็บฝั่ง n8n หรือ simple logging) | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 3) Backend Architecture (Node.js ESM / Express) | ||||
|  | ||||
| ### 3.1 Stack & Structure | ||||
|  | ||||
| * Node 20.x, **ESM** modules, **Express** | ||||
| * `mysql2/promise`, `jsonwebtoken`, `cookie-parser`, `cors`, `helmet`, `winston/morgan` | ||||
|  | ||||
| ```tree | ||||
| /backend/ | ||||
| ├─ src/ | ||||
| │  ├─ index.js               # bootstrap server, CORS, cookies, health | ||||
| │  ├─ routes/ | ||||
| │  │   ├─ auth.js            # /api/auth/* (login, me, logout) | ||||
| │  │   ├─ users.js           # /api/users/* | ||||
| │  │   ├─ correspondences.js # /api/correspondences/* | ||||
| │  │   ├─ drawings.js        # /api/drawings/* | ||||
| │  │   ├─ rfas.js            # /api/rfas/* | ||||
| │  │   └─ transmittals.js    # /api/transmittals/* | ||||
| │  ├─ middleware/ | ||||
| │  │   ├─ authGuard.js       # verify JWT from cookie | ||||
| │  │   ├─ requirePermission.js# RBAC/ABAC enforcement | ||||
| │  │   ├─ errorHandler.js | ||||
| │  │   └─ requestLogger.js | ||||
| │  ├─ db/ | ||||
| │  │   ├─ pool.js            # createPool, sane defaults | ||||
| │  │   └─ models/            # query builders (User, Drawing, ...) | ||||
| │  ├─ utils/ | ||||
| │  │   ├─ hash.js (bcrypt/argon2) | ||||
| │  │   ├─ jwt.js | ||||
| │  │   ├─ pagination.js | ||||
| │  │   └─ responses.js | ||||
| │  └─ config/ | ||||
| │      └─ index.js           # env, constants | ||||
| ├─ Dockerfile | ||||
| └─ package.json | ||||
| ``` | ||||
|  | ||||
| ### 3.2 Request Lifecycle | ||||
|  | ||||
| 1. `helmet` + `cors` (allow specific origin; credentials true) | ||||
| 2. `cookie-parser`, `json limit` (e.g., 2MB) | ||||
| 3. `requestLogger` → trace + response time | ||||
| 4. Route handler → `authGuard` (protected) → `requirePermission` (per‑route) → Controller | ||||
| 5. Error bubbles → `errorHandler` (JSON shape, status map) | ||||
|  | ||||
| ### 3.3 Auth & RBAC/ABAC | ||||
|  | ||||
| * **JWT** ใน HttpOnly cookie; Claims: `sub` (user_id), `roles`, `exp` | ||||
| * **authGuard**: ตรวจ token → แนบ `req.user` | ||||
| * **requirePermission**: เช็ค permission ตามเส้นทาง/วิธี; แผนขยาย ABAC (เช่น project scope, owner, doc state) | ||||
| * Roles/Permissions ถูก seed ใน SQL; มี **view เมทริกซ์** เพื่อ debug (เช่น `v_role_permission_matrix`) | ||||
|  | ||||
| **ตัวอย่าง pseudo** `requirePermission(permission)` | ||||
|  | ||||
| ```js | ||||
| export const requirePermission = (perm) => async (req, res, next) => { | ||||
|   if (!req.user) return res.status(401).json({ error: 'Unauthenticated' }); | ||||
|   const ok = await checkPermission(req.user.user_id, perm, req.context); | ||||
|   if (!ok) return res.status(403).json({ error: 'Forbidden' }); | ||||
|   return next(); | ||||
| }; | ||||
| ``` | ||||
|  | ||||
| ### 3.4 Database Access & Pooling | ||||
|  | ||||
| * `createPool({ connectionLimit: 10~25, queueLimit: 0, waitForConnections: true })` | ||||
| * ใช้ parameterized queries เสมอ; ปรับ `sql_mode` ที่จำเป็นใน `my.cnf` | ||||
|  | ||||
| ### 3.5 File Storage & Secure Download | ||||
|  | ||||
| * Root: **/share/dms‑data** | ||||
| * โครงโฟลเดอร์: `{module}/{yyyy}/{mm}/{entityId}/` + ชื่อไฟล์ตามมาตรฐาน (เช่น `DRW-<code>-REV-<rev>.pdf`) | ||||
| * Endpoint download: ตรวจสิทธิ์ (RBAC/ABAC) → `res.sendFile()`/stream; ป้องกัน path traversal | ||||
| * MIME allowlist + size limit + virus scan (optional; ภายหลัง) | ||||
|  | ||||
| ### 3.6 Health & Readiness | ||||
|  | ||||
| * `GET /api/health` → `{ ok: true }` | ||||
| * (optional) `/api/ready` ตรวจ DB ping + disk space (dms‑data) | ||||
|  | ||||
| ### 3.7 Config & ENV (BE) | ||||
|  | ||||
| * `DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME` | ||||
| * `JWT_SECRET, COOKIE_NAME, COOKIE_SAMESITE, COOKIE_SECURE` | ||||
| * `CORS_ORIGIN, LOG_LEVEL, APP_BASE_URL` | ||||
| * `FILE_ROOT=/share/dms-data` | ||||
|  | ||||
| ### 3.8 Logging | ||||
|  | ||||
| * Access log (morgan) + App log (winston) → `/share/Container/dms/logs/backend/` | ||||
| * รูปแบบ JSON (timestamp, level, msg, reqId) + daily rotation (logrotate/container‑side) | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 4) Database (MariaDB 10.11) | ||||
|  | ||||
| ### 4.1 Schema Overview (ย่อ) | ||||
|  | ||||
| * **RBAC core**: `users`, `roles`, `permissions`, `user_roles`, `role_permissions` | ||||
| * **Domain**: `drawings`, `contracts`, `correspondences`, `rfas`, `transmittals`, `organizations`, `projects`, ... | ||||
| * **Audit**: `audit_logs` (แผนขยาย), `deleted_at` (soft delete, แผนงาน) | ||||
|  | ||||
| ``` | ||||
| [users]──<user_roles>──[roles]──<role_permissions>──[permissions] | ||||
|    │ | ||||
|    └── activities/audit_logs (future expansion) | ||||
|  | ||||
| [drawings]──<mapping>──[contracts] | ||||
| [rfas]──<links>──[drawings] | ||||
| [correspondences] (internal/external flag) | ||||
| ``` | ||||
|  | ||||
| ### 4.2 Init SQL Pipeline | ||||
|  | ||||
| 1. `01_*_deploy_table_rbac.sql` — สร้างตารางหลักทั้งหมด + RBAC | ||||
| 2. `02_*_triggers.sql` — บังคับ data rules, auto‑audit fields | ||||
| 3. `03_*_procedures_handlers.sql` — upsert/bulk handlers (เช่น `sp_bulk_import_contract_dwg`) | ||||
| 4. `04_*_views.sql` — รายงาน/เมทริกซ์สิทธิ์ (`v_role_permission_matrix`, etc.) | ||||
| 5. `05_*_seed_data.sql` — ค่าพื้นฐาน domain (project, categories, statuses) | ||||
| 6. `06_*_seed_users.sql` — บัญชีเริ่มต้น (superadmin, editors, viewers) | ||||
| 7. `07_*_seed_contract_dwg.sql` — ข้อมูลตัวอย่างแบบสัญญา | ||||
|  | ||||
| ### 4.3 Indexing & Performance | ||||
|  | ||||
| * Composite indexes ตามคอลัมน์ filter/sort (เช่น `(project_id, updated_at DESC)`) | ||||
| * Full‑text index (optional) สำหรับ advanced search | ||||
| * Query plan review (EXPLAIN) + เพิ่ม covering index ตามรายงาน | ||||
|  | ||||
| ### 4.4 MySQL/MariaDB Config (my.cnf — แนวทาง) | ||||
|  | ||||
| ``` | ||||
| [mysqld] | ||||
| innodb_buffer_pool_size = 4G     # ปรับตาม RAM/QNAP | ||||
| innodb_log_file_size    = 512M | ||||
| innodb_flush_log_at_trx_commit = 1 | ||||
| max_connections         = 200 | ||||
| sql_mode                = STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION | ||||
| character-set-server    = utf8mb4 | ||||
| collation-server        = utf8mb4_unicode_ci | ||||
| ``` | ||||
|  | ||||
| > ปรับค่าให้เหมาะกับ workload จริง + เฝ้าดู IO/CPU ของ QNAP | ||||
|  | ||||
| ### 4.5 Backup/Restore | ||||
|  | ||||
| * Logical backup: `mysqldump --routines --triggers --single-transaction` | ||||
| * Physical (snapshot QNAP) + schedule ผ่าน n8n/cron | ||||
| * เก็บสำเนา off‑NAS (encrypted) | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 5) Reverse Proxy & TLS | ||||
|  | ||||
| ### 5.1 Nginx (Alpine) — ตัวอย่าง server block | ||||
|  | ||||
| > **สำคัญ:** บนสภาพแวดล้อมนี้ ให้ใช้คนละบรรทัด: | ||||
| > `listen 443 ssl;` | ||||
| > `http2 on;` | ||||
| > หลีกเลี่ยง `listen 443 ssl http2;` | ||||
|  | ||||
| ```nginx | ||||
| server { | ||||
|   listen 80; | ||||
|   server_name lcbp3.np-dms.work; | ||||
|   return 301 https://$host$request_uri; | ||||
| } | ||||
|  | ||||
| server { | ||||
|   listen 443 ssl; | ||||
|   http2 on; | ||||
|   server_name lcbp3.np-dms.work; | ||||
|  | ||||
|   ssl_certificate     /etc/nginx/certs/fullchain.pem; | ||||
|   ssl_certificate_key /etc/nginx/certs/privkey.pem; | ||||
|   add_header Strict-Transport-Security "max-age=63072000; preload" always; | ||||
|  | ||||
|   # Frontend | ||||
|   location / { | ||||
|     proxy_pass http://frontend:3000; | ||||
|     proxy_set_header Host $host; | ||||
|     proxy_set_header X-Real-IP $remote_addr; | ||||
|     proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | ||||
|     proxy_set_header X-Forwarded-Proto $scheme; | ||||
|   } | ||||
|  | ||||
|   # Next.js static | ||||
|   location /_next/ { | ||||
|     proxy_pass http://frontend:3000; | ||||
|   } | ||||
|  | ||||
|   # Backend API | ||||
|   location /api/ { | ||||
|     proxy_http_version 1.1; | ||||
|     proxy_set_header Connection ""; | ||||
|     proxy_pass http://backend:3001; | ||||
|     proxy_set_header Host $host; | ||||
|     proxy_set_header X-Real-IP $remote_addr; | ||||
|     proxy_set_header X-Forwarded-Proto $scheme; | ||||
|   } | ||||
|  | ||||
|   # phpMyAdmin (sub-path) | ||||
|   location /pma/ { | ||||
|     proxy_pass http://phpmyadmin:80/; | ||||
|   } | ||||
|  | ||||
|   # n8n | ||||
|   location /n8n/ { | ||||
|     proxy_pass http://n8n:5678/; | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### 5.2 Nginx Proxy Manager (NPM) — Tips | ||||
|  | ||||
| * ระวังอย่าใส่ `proxy_http_version` ซ้ำซ้อน (duplicate directive) ใน Advanced | ||||
| * ถ้าต้องแก้ไฟล์ด้านใน NPM → ระวังไฟล์ใน `/data/nginx/proxy_host/*.conf` | ||||
| * จัดการ certificate / SAN หลาย sub‑domain ใน UI แต่ mainten ดีเรื่อง symlink/renew | ||||
|  | ||||
| ### 5.3 TLS & Certificates | ||||
|  | ||||
| * Let’s Encrypt (HTTP‑01 webroot/standalone) + HSTS | ||||
| * QNAP mgmt เปลี่ยนเป็น 8443 → พอร์ต 443 public ว่างสำหรับ Nginx/NPM | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 6) Docker Compose Topology | ||||
|  | ||||
| ### 6.1 Services (สรุป) | ||||
|  | ||||
| * `frontend` (Next.js) :3000 | ||||
| * `backend` (Express) :3001 | ||||
| * `mariadb` (10.11)    :3306 (internal) | ||||
| * `phpmyadmin`         :80 (internal) | ||||
| * `nginx` or `npm`     :80/443 (published) | ||||
| * `n8n`                :5678 (internal) | ||||
| * `postgres_n8n` (16-alpine) | ||||
| * `pgadmin4` | ||||
|  | ||||
| ### 6.2 Volumes & Paths | ||||
|  | ||||
| ``` | ||||
| /share/Container/dms/ | ||||
| ├─ mariadb/data | ||||
| ├─ mariadb/init/*.sql | ||||
| ├─ backend/ (code) | ||||
| ├─ frontend/ (code) | ||||
| ├─ phpmyadmin/{sessions,tmp,config.user.inc.php} | ||||
| ├─ nginx/{nginx.conf,dms.conf,certs/} | ||||
| ├─ n8n, n8n-postgres, n8n-cache | ||||
| └─ logs/{backend,frontend,nginx,pgadmin,phpmyadmin,postgres_n8n} | ||||
| /share/dms-data  (pdf/dwg storage) | ||||
| ``` | ||||
|  | ||||
| ### 6.3 Healthchecks (suggested) | ||||
|  | ||||
| * **backend**: curl `http://localhost:3001/api/health` | ||||
| * **frontend**: curl `/health` (simple JSON) | ||||
| * **mariadb**: `mysqladmin ping` with credentials | ||||
| * **nginx**: `nginx -t` at startup | ||||
|  | ||||
| ### 6.4 Security Hardening | ||||
|  | ||||
| * รัน container ด้วย user non‑root (`user: node` สำหรับ FE/BE) | ||||
| * จำกัด capabilities; read‑only FS (ยกเว้นโวลุ่มจำเป็น) | ||||
| * เฉพาะ backend เมานต์ `/share/dms-data` | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 7) Observability, Ops, and Troubleshooting | ||||
|  | ||||
| ### 7.1 Logs | ||||
|  | ||||
| * Frontend → `/logs/frontend/*` | ||||
| * Backend  → `/logs/backend/*` (app/access/error) | ||||
| * Nginx/NPM → `/logs/nginx/*` | ||||
| * MariaDB   → default datadir log + slow query (เปิดใน my.cnf หากต้องการ) | ||||
|  | ||||
| ### 7.2 Common Issues & Playbooks | ||||
|  | ||||
| * **401 Unauthenticated**: ตรวจ `authGuard` → JWT cookie มี/หมดอายุ → เวลา server/FE sync → CORS `credentials: true` | ||||
| * **EACCES Next.js**: สิทธิ์ `.next/*` + run as `node`, โวลุ่ม map ถูก user:group | ||||
| * **NPM duplicate directive**: ลบซ้ำ `proxy_http_version` ใน Advanced / ตรวจ `proxy_host/*.conf` | ||||
| * **LE cert path/symlink**: ตรวจ `/etc/letsencrypt/live/npm-*` symlink ชี้ถูก | ||||
| * **DB field not found**: ตรวจ schema vs code (migration/init SQL) → sync ให้ตรง | ||||
|  | ||||
| ### 7.3 Performance Guides | ||||
|  | ||||
| * **Backend**: keep‑alive, gzip/deflate at proxy, pool 10–25, paginate, avoid N+1 | ||||
| * **Frontend**: prefetch critical routes, cache static, image optimization | ||||
| * **DB**: เพิ่ม index จุด filter, analyze query (EXPLAIN), ปรับ buffer pool | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 8) Security & Compliance | ||||
|  | ||||
| * **HTTPS only** + HSTS (preload) | ||||
| * **CORS**: allow list เฉพาะ FE origin; `Access-Control-Allow-Credentials: true` | ||||
| * **Cookie**: HttpOnly, Secure, SameSite=Lax/Strict | ||||
| * **Input Validation**: celebrate/zod (optional) + sanitize | ||||
| * **Rate limiting**: per IP/route (optional) | ||||
| * **AuditLog**: วางแผนเพิ่ม ครอบคลุม CRUD + mapping (actor, action, entity, before/after) | ||||
| * **Backups**: DB + `/share/dms-data` + config (encrypted off‑NAS) | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 9) Backlog → Architecture Mapping | ||||
|  | ||||
| 1. **RBAC Enforcement ครบ** → เติม `requirePermission` ทุก route + test matrix ผ่าน view | ||||
| 2. **AuditLog ครบ CRUD/Mapping** → trigger + table `audit_logs` + BE hook | ||||
| 3. **Upload/Download จริงของ Drawing Revisions** → BE endpoints + virus scan (optional) | ||||
| 4. **Dashboard KPI** → BE summary endpoints + FE cards/charts | ||||
| 5. **Server‑side DataTables** → paging/sort/filter + indexesรองรับ | ||||
| 6. **รายงาน Export CSV/Excel/PDF** → BE export endpoints + FE buttons | ||||
| 7. **Soft delete** (`deleted_at`) → BE filter default scope + restore endpoint | ||||
| 8. **Validation เข้ม** → celebrate/zod schema + consistent error shape | ||||
| 9. **Indexing/Perf** → slow query log + EXPLAIN review | ||||
| 10. **Job/Cron Deadline Alerts** → n8n schedule + SMTP | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 10) Port & ENV Matrix (Quick Ref) | ||||
|  | ||||
| | Component | Ports           | Key ENV                                          | | ||||
| | --------- | --------------- | ------------------------------------------------ | | ||||
| | Nginx/NPM | 80/443 (public) | SSL paths, HSTS                                  | | ||||
| | Frontend  | 3000 (internal) | `NEXT_PUBLIC_API_BASE`                           | | ||||
| | Backend   | 3001 (internal) | `DB_*`, `JWT_SECRET`, `CORS_ORIGIN`, `FILE_ROOT` | | ||||
| | MariaDB   | 3306 (internal) | `MY_CNF`, credentials                            | | ||||
| | n8n       | 5678 (internal) | `N8N_*`, webhook URL under `/n8n/`               | | ||||
| | Postgres  | 5432 (internal) | n8n DB                                           | | ||||
|  | ||||
| **QNAP mgmt**: 8443 (already moved) | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 11) Sample Snippets | ||||
|  | ||||
| ### 11.1 Backend CORS (credentials) | ||||
|  | ||||
| ```js | ||||
| app.use(cors({ | ||||
|   origin: ['https://lcbp3.np-dms.work'], | ||||
|   credentials: true, | ||||
| })); | ||||
| ``` | ||||
|  | ||||
| ### 11.2 Secure Download (guarded) | ||||
|  | ||||
| ```js | ||||
| router.get('/files/:module/:id/:filename', authGuard, requirePermission('file.read'), async (req, res) => { | ||||
|   const { module, id, filename } = req.params; | ||||
|   // 1) ABAC: verify user can access this module/entity | ||||
|   const ok = await canReadFile(req.user.user_id, module, id); | ||||
|   if (!ok) return res.status(403).json({ error: 'Forbidden' }); | ||||
|  | ||||
|   const abs = path.join(FILE_ROOT, module, id, filename); | ||||
|   if (!abs.startsWith(FILE_ROOT)) return res.status(400).json({ error: 'Bad path' }); | ||||
|   return res.sendFile(abs); | ||||
| }); | ||||
| ``` | ||||
|  | ||||
| ### 11.3 Healthcheck | ||||
|  | ||||
| ```js | ||||
| router.get('/health', (req, res) => res.json({ ok: true })); | ||||
| ``` | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 12) Deployment Workflow (Suggested) | ||||
|  | ||||
| 1. Git (Gitea) branch strategy `feature/*` → PR → main | ||||
| 2. Build images (dev/prod) via Dockerfile multi‑stage; pin Node/MariaDB versions | ||||
| 3. `docker compose up -d --build` จาก `/share/Container/dms` | ||||
| 4. Validate: `/health`, `/api/health`, login roundtrip | ||||
| 5. Monitor logs + baseline perf; run SQL smoke tests (views/triggers/procs) | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 13) Appendix | ||||
|  | ||||
| * **Naming conventions**: snake_case DB, camelCase JS | ||||
| * **Timezones**: store UTC in DB; display in app TZ (+07:00) | ||||
| * **Character set**: UTF‑8 (`utf8mb4_unicode_ci`) | ||||
| * **Large file policy**: size limit (e.g., 50–200MB), allowlist extensions | ||||
| * **Retention**: archive strategy for old revisions (optional) | ||||
|  | ||||
| --- | ||||
|  | ||||
| > หากต้องการ เวอร์ชัน **README.md พร้อมโค้ดตัวอย่าง compose/nginx** จัดรูปแบบให้นำไปวางใน repo ได้ทันที แจ้งได้เลยว่าจะให้แตกไฟล์เป็น `/docs/Architecture.md` + `/nginx/dms.conf` + `/docker-compose.yml` template หรือรูปแบบอื่นที่สะดวกต่อการใช้งานของทีม | ||||
							
								
								
									
										712
									
								
								README.md
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										712
									
								
								README.md
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							| @@ -1,4 +1,665 @@ | ||||
| # บทบาท: คุณคือ Programmer และ Document Engineer ที่เชี่ยวชาญ | ||||
| # 📝 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/dms‑data พร้อมมาตรฐานการตั้งชื่อ+โฟลเดอร์ | ||||
| - Dev/Prod แยกชัดเจนผ่าน Docker multi‑stage + docker‑compose + โฟลเดอร์ persist logs/config/certs | ||||
|  | ||||
| ### 1.2 Runtime Topology & Trust Boundaries | ||||
|  | ||||
| ```text | ||||
| Internet Clients (Browser) | ||||
|     │   HTTPS 443 (HSTS)  [QNAP mgmt = 8443] | ||||
|     ▼ | ||||
| ┌─────────────────────────────────────────────────────┐ | ||||
| │ Reverse Proxy Layer                                 │ | ||||
| │  ├─ Nginx (Alpine)  or  Nginx Proxy Manager (NPM)   │ | ||||
| │  ├─ TLS (LE cert; SAN multi‑subdomain)              │ | ||||
| │  └─ Routes:                                         │ | ||||
| │     • /, /_next/*   → Frontend (Next.js :3000)      │ | ||||
| │     • /api/*        → Backend (Express :3001)       │ | ||||
| │     • /pma/*        → phpMyAdmin                    │ | ||||
| │     • /n8n/*        → n8n (Workflows)               │ | ||||
| └─────────────────────────────────────────────────────┘ | ||||
|                │                         │ | ||||
|                │                         └──────────┐ | ||||
|                ▼                                    │ | ||||
|          Frontend (Next.js)                         │ | ||||
|                │  Cookie-based Auth (HttpOnly)      │ | ||||
|                ▼                                    ▼ | ||||
|          Backend (Node/Express ESM)  ─────────►  MariaDB 10.11 | ||||
|                │                                    │ | ||||
|                └────────────────────────────────────┘ | ||||
|                Project data (.pdf/.dwg) @ /share/dms-data | ||||
|  | ||||
|          n8n (workflows) ──► Postgres 16  (separate DB for automations) | ||||
| ``` | ||||
|  | ||||
| ==Trust Boundaries== | ||||
|  | ||||
| - Public zone: Internet ↔ Reverse proxy | ||||
| - App zone: Reverse proxy ↔ FE/BE containers (internal Docker network) | ||||
| - # Data zone: Backend ↔ Databases (MariaDB, Postgres) + /share/dms-data | ||||
|  | ||||
| ### 1.3 Frontend: Next.js (ESM) / React.js | ||||
|  | ||||
| #### 1.3.1 Stack & Key libs | ||||
|  | ||||
| - Next.js (App Router), React, ESM | ||||
| - Tailwind CSS, PostCSS, shadcn/ui (components.json) | ||||
| - Fetch API (credentials include) → Cookie Auth (HttpOnly) | ||||
|  | ||||
| #### 1.3.2 Directory Layout | ||||
|  | ||||
| ```text | ||||
| /frontend/ | ||||
| ├─ app/ | ||||
| │  ├─ login/ | ||||
| │  ├─ dashboard/ | ||||
| │  ├─ users/ | ||||
| │  ├─ correspondences/ | ||||
| │  ├─ health/ | ||||
| │  └─ layout.tsx / page.tsx (ตาม App Router) | ||||
| ├─ public/ | ||||
| ├─ Dockerfile (multi-stage: dev/prod) | ||||
| ├─ package.json | ||||
| ├─ next.config.js | ||||
| └─ ... | ||||
| ``` | ||||
|  | ||||
| #### 1.3.3 Routing & Layouts | ||||
|  | ||||
| - Public /login, /health | ||||
| - Protected: /dashboard, /users, /correspondences, ... (client-side guard) | ||||
| - เก็บ middleware.ts (ของเดิม) เพื่อหลีกเลี่ยง regression; ใช้ client‑guard + server action อย่างระมัดระวัง | ||||
|  | ||||
| #### 1.3.4 Auth Flow (Cookie-based) | ||||
|  | ||||
| 1. ผู้ใช้ submit form /login → POST /api/auth/login (Backend) | ||||
| 2. Backend set HttpOnly cookie (JWT) + SameSite=Lax/Strict, Secure | ||||
| 3. หน้า protected เรียก GET /api/auth/me เพื่อตรวจสอบสถานะ | ||||
| 4. หาก 401 → redirect → /login | ||||
|  | ||||
| **CORS/Fetch**: เเปิด credentials: 'include' ทุกครั้ง, ตั้ง NEXT_PUBLIC_API_BASE เป็น origin ของ backend ผ่าน proxy (เช่น https://lcbp3.np-dms.work) | ||||
|  | ||||
| #### 1.3.5 UI/UX | ||||
|  | ||||
| - Sea‑blue palette, sidebar พับได้, card‑based KPI | ||||
| - ตารางข้อมูลเตรียมรองรับ server‑side DataTables\*\* | ||||
| - shadcn/ui: Button, Card, Badge, Tabs, Dropdown, Tooltip, Switch, etc. | ||||
|  | ||||
| #### 1.3.6 Config & ENV | ||||
|  | ||||
| - NEXT_PUBLIC_API_BAS (ex: https://lcbp3.np-dms.work) | ||||
| - Build output แยก dev/prod; ระวัง EACCES บน QNAP → ใช้ user node + ปรับสิทธิ์โวลุ่ม .next/\* | ||||
|  | ||||
| #### 1.3.7 Error Handling & Observability (FE) | ||||
|  | ||||
| - Global error boundary (app router) + toast/alert patterns | ||||
| - Network layer: แยก handler สำหรับ 401/403/500 + retry/backoff ที่จำเป็น | ||||
| - Metrics (optional): web‑vitals, UX timing (เก็บฝั่ง n8n หรือ simple logging) | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### 1.4 Backend Architecture (Node.js ESM / Express) | ||||
|  | ||||
| #### 1.4.1 Stack & Structure | ||||
|  | ||||
| - Node 20.x, ESM modules, Express\*\* | ||||
| - mysql2/promise, jsonwebtoken, cookie-parser, cors, helmet, winston/morgan | ||||
|  | ||||
| ```text | ||||
| /backend/ | ||||
| ├─ src/ | ||||
| │  ├─ index.js               # bootstrap server, CORS, cookies, health | ||||
| │  ├─ routes/ | ||||
| │  │   ├─ auth.js            # /api/auth/* (login, me, logout) | ||||
| │  │   ├─ users.js           # /api/users/* | ||||
| │  │   ├─ correspondences.js # /api/correspondences/* | ||||
| │  │   ├─ drawings.js        # /api/drawings/* | ||||
| │  │   ├─ rfas.js            # /api/rfas/* | ||||
| │  │   └─ transmittals.js    # /api/transmittals/* | ||||
| │  ├─ middleware/ | ||||
| │  │   ├─ authGuard.js       # verify JWT from cookie | ||||
| │  │   ├─ requirePermission.js# RBAC/ABAC enforcement | ||||
| │  │   ├─ errorHandler.js | ||||
| │  │   └─ requestLogger.js | ||||
| │  ├─ db/ | ||||
| │  │   ├─ pool.js            # createPool, sane defaults | ||||
| │  │   └─ models/            # query builders (User, Drawing, ...) | ||||
| │  ├─ utils/ | ||||
| │  │   ├─ hash.js (bcrypt/argon2) | ||||
| │  │   ├─ jwt.js | ||||
| │  │   ├─ pagination.js | ||||
| │  │   └─ responses.js | ||||
| │  └─ config/ | ||||
| │      └─ index.js           # env, constants | ||||
| ├─ Dockerfile | ||||
| └─ package.json | ||||
| ``` | ||||
|  | ||||
| #### 1.4.2 Request Lifecycle | ||||
|  | ||||
| 1. helmet + cors (allow specific origin; credentials true) | ||||
| 2. cookie-parser, json limit (e.g., 2MB) | ||||
| 3. requestLogger → trace + response time | ||||
| 4. Route handler → authGuard (protected) → requirePermission (per‑route) → Controller | ||||
| 5. Error bubbles → errorHandler (JSON shape, status map) | ||||
|  | ||||
| #### 1.4.3 Auth & RBAC/ABAC | ||||
|  | ||||
| - JWT ใน HttpOnly cookie; Claims: sub (user_id), roles, exp | ||||
| - authGuard: ตรวจ token → แนบ req.user | ||||
| - requirePermission: เช็ค permission ตามเส้นทาง/วิธี; แผนขยาย ABAC (เช่น project scope, owner, doc state) | ||||
| - Roles/Permissions ถูก seed ใน SQL; มี view เมทริกซ์ เพื่อ debug (เช่น v_role_permission_matrix) | ||||
|  | ||||
| \*\*ตัวอย่าง pseudo requirePermission(permission) | ||||
|  | ||||
| ```js | ||||
| export const requirePermission = (perm) => async (req, res, next) => { | ||||
|   if (!req.user) return res.status(401).json({ error: "Unauthenticated" }); | ||||
|   const ok = await checkPermission(req.user.user_id, perm, req.context); | ||||
|   if (!ok) return res.status(403).json({ error: "Forbidden" }); | ||||
|   return next(); | ||||
| }; | ||||
| ``` | ||||
|  | ||||
| #### 1.4.4 Database Access & Pooling | ||||
|  | ||||
| - createPool({ connectionLimit: 10~25, queueLimit: 0, waitForConnections: true }) | ||||
| - ใช้ parameterized queries เสมอ; ปรับ sql_mode ที่จำเป็นใน my.cnf | ||||
|  | ||||
| #### 1.4.5 File Storage & Secure Download | ||||
|  | ||||
| - Root: /share/dms‑data | ||||
| - โครงโฟลเดอร์: {module}/{yyyy}/{mm}/{entityId}/ + ชื่อไฟล์ตามมาตรฐาน (เช่น DRW-code-REV-rev.pdf) | ||||
| - Endpoint download: ตรวจสิทธิ์ (RBAC/ABAC) → res.sendFile()/stream; ป้องกัน path traversal | ||||
| - MIME allowlist + size limit + virus scan (optional; ภายหลัง) | ||||
|  | ||||
| #### 1.4.6 Health & Readiness | ||||
|  | ||||
| - GET /api/health → { ok: true } | ||||
| - (optional) /api/ready ตรวจ DB ping + disk space (dms‑data) | ||||
|  | ||||
| #### 1.4.7 Config & ENV (BE) | ||||
|  | ||||
| - DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME | ||||
| - JWT_SECRET, COOKIE_NAME, COOKIE_SAMESITE, COOKIE_SECURE | ||||
| - CORS_ORIGIN, LOG_LEVEL, APP_BASE_URL | ||||
| - FILE_ROOT=/share/dms-data | ||||
|  | ||||
| #### 1.4.8 Logging | ||||
|  | ||||
| - Access log (morgan) + App log (winston) → /share/Container/dms/logs/backend/ | ||||
| - รูปแบบ JSON (timestamp, level, msg, reqId) + daily rotation (logrotate/container‑side) | ||||
|  | ||||
| ### 1.5 Database (MariaDB 10.11) | ||||
|  | ||||
| #### 1.5.1 Schema Overview (ย่อ) | ||||
|  | ||||
| - RBAC core: users, roles, permissions, user_roles, role_permissions | ||||
| - Domain: drawings, contracts, correspondences, rfas, transmittals, organizations, projects, ... | ||||
| - Audit: audit_logs (แผนขยาย), deleted_at (soft delete, แผนงาน) | ||||
|  | ||||
| ```text | ||||
| [users]──<user_roles>──[roles]──<role_permissions>──[permissions] | ||||
|    │ | ||||
|    └── activities/audit_logs (future expansion) | ||||
|  | ||||
| [drawings]──<mapping>──[contracts] | ||||
| [rfas]──<links>──[drawings] | ||||
| [correspondences] (internal/external flag) | ||||
| ``` | ||||
|  | ||||
| #### 1.5.2 Init SQL Pipeline | ||||
|  | ||||
| 1. 01\_\*\_deploy_table_rbac.sql — สร้างตารางหลักทั้งหมด + RBAC | ||||
| 2. 02\_\*\_triggers.sql — บังคับ data rules, auto‑audit fields | ||||
| 3. 03\_\*\_procedures_handlers.sql — upsert/bulk handlers (เช่น sp_bulk_import_contract_dwg) | ||||
| 4. 04\_\*\_views.sql — รายงาน/เมทริกซ์สิทธิ์ (v_role_permission_matrix, etc.) | ||||
| 5. 05\_\*\_seed_data.sql — ค่าพื้นฐาน domain (project, categories, statuses) | ||||
| 6. 06\_\*\_seed_users.sql — บัญชีเริ่มต้น (superadmin, editors, viewers) | ||||
| 7. 07\_\*\_seed_contract_dwg.sql — ข้อมูลตัวอย่างแบบสัญญา | ||||
|  | ||||
| #### 1.5.3 Indexing & Performance | ||||
|  | ||||
| - Composite indexes ตามคอลัมน์ filter/sort (เช่น (project_id, updated_at DESC)) | ||||
| - Full‑text index (optional) สำหรับ advanced search | ||||
| - Query plan review (EXPLAIN) + เพิ่ม covering index ตามรายงาน | ||||
|  | ||||
| #### 1.5.4 MySQL/MariaDB Config (my.cnf — แนวทาง) | ||||
|  | ||||
| ```conf | ||||
| [mysqld] | ||||
| innodb_buffer_pool_size = 4G     # ปรับตาม RAM/QNAP | ||||
| innodb_log_file_size    = 512M | ||||
| innodb_flush_log_at_trx_commit = 1 | ||||
| max_connections         = 200 | ||||
| sql_mode                = STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION | ||||
| character-set-server    = utf8mb4 | ||||
| collation-server        = utf8mb4_unicode_ci | ||||
| ``` | ||||
|  | ||||
| > ปรับค่าให้เหมาะกับ workload จริง + เฝ้าดู IO/CPU ของ QNAP | ||||
|  | ||||
| #### 1.5.5 Backup/Restore | ||||
|  | ||||
| - Logical backup: mysqldump --routines --triggers --single-transaction | ||||
| - Physical (snapshot QNAP) + schedule ผ่าน n8n/cron | ||||
| - เก็บสำเนา off‑NAS (encrypted) | ||||
|  | ||||
| ### 1.6 Reverse Proxy & TLS | ||||
|  | ||||
| #### 1.6.1 Nginx (Alpine) — ตัวอย่าง server block | ||||
|  | ||||
| > สำคัญ: บนสภาพแวดล้อมนี้ ให้ใช้คนละบรรทัด:   | ||||
| > listen 443 ssl; | ||||
| > http2 on; | ||||
| > หลีกเลี่ยง listen 443 ssl http2; | ||||
|  | ||||
| ```conf | ||||
| server { | ||||
|   listen 80; | ||||
|   server_name lcbp3.np-dms.work; | ||||
|   return 301 https://$host$request_uri; | ||||
| } | ||||
|  | ||||
| server { | ||||
|   listen 443 ssl; | ||||
|   http2 on; | ||||
|   server_name lcbp3.np-dms.work; | ||||
|  | ||||
|   ssl_certificate     /etc/nginx/certs/fullchain.pem; | ||||
|   ssl_certificate_key /etc/nginx/certs/privkey.pem; | ||||
|   add_header Strict-Transport-Security "max-age=63072000; preload" always; | ||||
|  | ||||
|   # Frontend | ||||
|   location / { | ||||
|     proxy_pass http://frontend:3000; | ||||
|     proxy_set_header Host $host; | ||||
|     proxy_set_header X-Real-IP $remote_addr; | ||||
|     proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | ||||
|     proxy_set_header X-Forwarded-Proto $scheme; | ||||
|   } | ||||
|  | ||||
|   # Next.js static | ||||
|   location /_next/ { | ||||
|     proxy_pass http://frontend:3000; | ||||
|   } | ||||
|  | ||||
|   # Backend API | ||||
|   location /api/ { | ||||
|     proxy_http_version 1.1; | ||||
|     proxy_set_header Connection ""; | ||||
|     proxy_pass http://backend:3001; | ||||
|     proxy_set_header Host $host; | ||||
|     proxy_set_header X-Real-IP $remote_addr; | ||||
|     proxy_set_header X-Forwarded-Proto $scheme; | ||||
|   } | ||||
|  | ||||
|   # phpMyAdmin (sub-path) | ||||
|   location /pma/ { | ||||
|     proxy_pass http://phpmyadmin:80/; | ||||
|   } | ||||
|  | ||||
|   # n8n | ||||
|   location /n8n/ { | ||||
|     proxy_pass http://n8n:5678/; | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 1.6.2 Nginx Proxy Manager (NPM) — Tips | ||||
|  | ||||
| - ระวังอย่าใส่ proxy_http_version ซ้ำซ้อน (duplicate directive) ใน Advanced | ||||
| - ถ้าต้องแก้ไฟล์ด้านใน NPM → ระวังไฟล์ใน /data/nginx/proxy_host/\*.conf | ||||
| - จัดการ certificate / SAN หลาย sub‑domain ใน UI แต่ mainten ดีเรื่อง symlink/renew | ||||
|  | ||||
| #### 1.6.3 TLS & Certificates | ||||
|  | ||||
| - Let’s Encrypt (HTTP‑01 webroot/standalone) + HSTS | ||||
| - QNAP mgmt เปลี่ยนเป็น 8443 → พอร์ต 443 public ว่างสำหรับ Nginx/NPM | ||||
|  | ||||
| ### 1.7 Docker Compose Topology | ||||
|  | ||||
| #### 1.7.1 Services (สรุป) | ||||
|  | ||||
| - frontend (Next.js) :3000 | ||||
| - backend (Express) :3001 | ||||
| - mariadb (10.11) :3306 (internal) | ||||
| - phpmyadmin :80 (internal) | ||||
| - nginx or npm :80/443 (published) | ||||
| - n8n :5678 (internal) | ||||
| - postgres_n8n (16-alpine) | ||||
| - pgadmin4 | ||||
|  | ||||
| #### 1.7.2 Volumes & Paths | ||||
|  | ||||
| ```text | ||||
| /share/Container/dms/ | ||||
| ├─ mariadb/data | ||||
| ├─ mariadb/init/*.sql | ||||
| ├─ backend/ (code) | ||||
| ├─ frontend/ (code) | ||||
| ├─ phpmyadmin/{sessions,tmp,config.user.inc.php} | ||||
| ├─ nginx/{nginx.conf,dms.conf,certs/} | ||||
| ├─ n8n, n8n-postgres, n8n-cache | ||||
| └─ logs/{backend,frontend,nginx,pgadmin,phpmyadmin,postgres_n8n} | ||||
| /share/dms-data  (pdf/dwg storage) | ||||
| ``` | ||||
|  | ||||
| #### 1.7.3 Healthchecks (suggested) | ||||
|  | ||||
| - backend: | ||||
|  | ||||
|   ```sh | ||||
|   curl http://localhost:3001/api/health | ||||
|   ``` | ||||
|  | ||||
| - frontend: curl /health (simple JSON) | ||||
| - mariadb: mysqladmin ping with credentials | ||||
| - nginx: nginx -t at startup | ||||
|  | ||||
| #### 1.7.4 Security Hardening | ||||
|  | ||||
| - รัน container ด้วย user non‑root (user: node สำหรับ FE/BE) | ||||
| - จำกัด capabilities; read‑only FS (ยกเว้นโวลุ่มจำเป็น) | ||||
| - เฉพาะ backend เมานต์ /share/dms-data | ||||
|  | ||||
| ### 1.8 Observability, Ops, and Troubleshooting | ||||
|  | ||||
| #### 1.8.1 Logs | ||||
|  | ||||
| - Frontend → /logs/frontend/\* | ||||
| - Backend → /logs/backend/\* (app/access/error) | ||||
| - Nginx/NPM → /logs/nginx/\* | ||||
| - MariaDB → default datadir log + slow query (เปิดใน my.cnf หากต้องการ) | ||||
|  | ||||
| #### 1.8.2 Common Issues & Playbooks | ||||
|  | ||||
| - 401 Unauthenticated: ตรวจ authGuard → JWT cookie มี/หมดอายุ → เวลา server/FE sync → CORS credentials: true | ||||
| - EACCES Next.js: สิทธิ์ .next/\* + run as`node, โวลุ่ม map ถูก user:group | ||||
| - NPM duplicate directive: ลบซ้ำ proxy_http_version ใน Advanced / ตรวจ proxy_host/\*.conf | ||||
| - LE cert path/symlink: ตรวจ /etc/letsencrypt/live/npm-\* symlink ชี้ถูก | ||||
| - DB field not found: ตรวจ schema vs code (migration/init SQL) → sync ให้ตรง | ||||
|  | ||||
| #### 1.8.3 Performance Guides | ||||
|  | ||||
| - Backend: keep‑alive, gzip/deflate at proxy, pool 10–25, paginate, avoid N+1 | ||||
| - Frontend: prefetch critical routes, cache static, image optimization | ||||
| - DB: เพิ่ม index จุด filter, analyze query (EXPLAIN), ปรับ buffer pool | ||||
|  | ||||
| ### 1.9 Security & Compliance | ||||
|  | ||||
| - HTTPS only + HSTS (preload) | ||||
| - CORS: allow list เฉพาะ FE origin; Access-Control-Allow-Credentials: true | ||||
| - Cookie: HttpOnly, Secure, SameSite=Lax/Strict | ||||
| - Input Validation: celebrate/zod (optional) + sanitize | ||||
| - Rate limiting: per IP/route (optional) | ||||
| - AuditLog: วางแผนเพิ่ม ครอบคลุม CRUD + mapping (actor, action, entity, before/after) | ||||
| - Backups: DB + /share/dms-data + config (encrypted off‑NAS) | ||||
|  | ||||
| ### 1.10 Backlog → Architecture Mapping | ||||
|  | ||||
| 1. RBAC Enforcement ครบ → เติม requirePermission ทุก route + test matrix ผ่าน view | ||||
| 2. AuditLog ครบ CRUD/Mapping → trigger + table audit_logs + BE hook | ||||
| 3. Upload/Download จริงของ Drawing Revisions → BE endpoints + virus scan (optional) | ||||
| 4. Dashboard KPI → BE summary endpoints + FE cards/charts | ||||
| 5. Server‑side DataTables → paging/sort/filter + indexesรองรับ | ||||
| 6. รายงาน Export CSV/Excel/PDF → BE export endpoints + FE buttons | ||||
| 7. Soft delete (deleted_at) → BE filter default scope + restore endpoint | ||||
| 8. Validation เข้ม → celebrate/zod schema + consistent error shape | ||||
| 9. Indexing/Perf → slow query log + EXPLAIN review | ||||
| 10. Job/Cron Deadline Alerts → n8n schedule + SMTP | ||||
|  | ||||
| ### 1.11 Port & ENV Matrix (Quick Ref) | ||||
|  | ||||
| | Component | Ports | Key ENV | | ||||
| | Nginx/NPM | 80/443 (public) | SSL paths, HSTS | | ||||
| | Frontend | 3000 (internal) | NEXT*PUBLIC_API_BASE | | ||||
| | Backend | 3001 (internal) | DB*\*, JWT*SECRET, CORS_ORIGIN, FILE_ROOT | | ||||
| | MariaDB | 3306 (internal) | MY_CNF, credentials | | ||||
| | n8n | 5678 (internal) | N8N*, webhook URL under /n8n/ | | ||||
| | Postgres | 5432 (internal) | n8n DB | | ||||
|  | ||||
| QNAP mgmt: 8443 (already moved) | ||||
|  | ||||
| ### 1.12 Sample Snippets | ||||
|  | ||||
| #### 1.12.1 Backend CORS (credentials) | ||||
|  | ||||
| ```js | ||||
| app.use( | ||||
|   cors({ | ||||
|     origin: ["https://lcbp3.np-dms.work"], | ||||
|     credentials: true, | ||||
|   }) | ||||
| ); | ||||
| ``` | ||||
|  | ||||
| #### 1.12.2 Secure Download (guarded) | ||||
|  | ||||
| ```js | ||||
| router.get( | ||||
|   "/files/:module/:id/:filename", | ||||
|   authGuard, | ||||
|   requirePermission("file.read"), | ||||
|   async (req, res) => { | ||||
|     const { module, id, filename } = req.params; | ||||
|     // 1) ABAC: verify user can access this module/entity | ||||
|     const ok = await canReadFile(req.user.user_id, module, id); | ||||
|     if (!ok) return res.status(403).json({ error: "Forbidden" }); | ||||
|  | ||||
|     const abs = path.join(FILE_ROOT, module, id, filename); | ||||
|     if (!abs.startsWith(FILE_ROOT)) | ||||
|       return res.status(400).json({ error: "Bad path" }); | ||||
|     return res.sendFile(abs); | ||||
|   } | ||||
| ); | ||||
| ``` | ||||
|  | ||||
| #### 1.12.3 Healthcheck | ||||
|  | ||||
| ```js | ||||
| router.get("/health", (req, res) => res.json({ ok: true })); | ||||
| ``` | ||||
|  | ||||
| ### 13 Deployment Workflow (Suggested) | ||||
|  | ||||
| 1. Git (Gitea) branch strategy feature/\* → PR → main | ||||
| 2. Build images (dev/prod) via Dockerfile multi‑stage; pin Node/MariaDB versions | ||||
| 3. docker compose up -d --build จาก /share/Container/dms | ||||
| 4. Validate: /health, /api/health, login roundtrip | ||||
| 5. Monitor logs + baseline perf; run SQL smoke tests (views/triggers/procs) | ||||
|  | ||||
| ### 14 Appendix | ||||
|  | ||||
| - Naming conventions: snake_case DB, camelCase JS | ||||
| - Timezones: store UTC in DB; display in app TZ (+07:00) | ||||
| - Character set: UTF‑8 (utf8mb4_unicode_ci) | ||||
| - Large file policy: size limit (e.g., 50–200MB), allowlist extensions | ||||
| - Retention: archive strategy for old revisions (optional) | ||||
|  | ||||
| ## บทบาท: คุณคือ Programmer และ Document Engineer ที่เชี่ยวชาญ | ||||
|  | ||||
| 1. การพัฒนาเว็บแอป (Web Application Development) | ||||
| 2. Configuration of Container Station on QNAP | ||||
| 3. Database: mariadb:10.11 | ||||
| @@ -16,14 +677,19 @@ | ||||
| 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 แล้ว *** | ||||
|  | ||||
| - ใช้ 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/ | ||||
| @@ -83,23 +749,29 @@ | ||||
| └─ 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 | ||||
|  | ||||
| # กรณี 2: มี Git อยู่แล้ว (มี main อยู่) | ||||
|  | ||||
| 2.1 อัปเดต main ให้ตรงล่าสุดก่อนแตกบร้านช์ | ||||
|  | ||||
| cd /share/Container/dms | ||||
| git checkout main | ||||
| git pull --ff-only # ถ้าเชื่อม remote อยู่ | ||||
| git tag -f stable-$(date +%F) # tag จุดเสถียรปัจจุบัน | ||||
|  | ||||
| 2.2 แตก branch งาน Dashboard | ||||
| git checkout -b feature/dashboard-update-$(date +%y%m%d) | ||||
| git checkout -b feature/dashboard-update-251004 | ||||
|  | ||||
| 2.3 ทำงาน/คอมมิตตามปกติ | ||||
|  | ||||
| # แก้ไฟล์ frontend/app/dashboard/\* และที่เกี่ยวข้อง | ||||
|  | ||||
| git add frontend/app/dashboard | ||||
| git commit -m "feat(dashboard): เพิ่มส่วนจัดการ user" | ||||
| git push -u origin feature/dashboard-update-251004 | ||||
|   | ||||
							
								
								
									
										96
									
								
								b.env
									
									
									
									
									
								
							
							
						
						
									
										96
									
								
								b.env
									
									
									
									
									
								
							| @@ -1,96 +0,0 @@ | ||||
| TZ=Asia/Bangkok | ||||
| GENERIC_TIMEZONE=Asia/Bangkok | ||||
| PUBLIC_DOMAIN=np-dms.work | ||||
|  | ||||
| PUBLIC_FRONTEND_URL=https://lcbp3.np-dms.work | ||||
| PUBLIC_BACKEND_URL=https://lcbp3.np-dms.work/api | ||||
| PUBLIC_N8N_URL=https://lcbp3.np-dms.work/n8n | ||||
|  | ||||
| MARIADB_HOST=mariadb | ||||
| MARIADB_PORT=3306 | ||||
| MARIADB_ROOT_PASSWORD=Center#2025 | ||||
| MARIADB_DATABASE=dms | ||||
| MARIADB_USER=center | ||||
| MARIADB_PASSWORD=Center#2025 | ||||
|  | ||||
| # MARIADB_HOST_PORT=7307 | ||||
| # BACKEND_HOST_PORT=7001 | ||||
| # FRONTEND_HOST_PORT=7000 | ||||
| # PHPMYADMIN_HOST_PORT=7070 | ||||
| NGINX_HTTP_HOST_PORT=80 | ||||
| NGINX_HTTPS_HOST_PORT=443 | ||||
| N# 8N_HOST_PORT=7081 | ||||
|  | ||||
| NODE_ENV=production | ||||
| JWT_SECRET=8b0df02e4aee9f9f79a4f2d8ba77b0b82c1ee3446b68cb0bae94ab54d60f8d9e | ||||
| JWT_EXPIRES_IN=12h | ||||
| PASSWORD_SALT_ROUNDS=10 | ||||
| RATE_LIMIT_WINDOW_MS=900000 | ||||
| RATE_LIMIT_MAX=200 | ||||
| CORS_ORIGINS=https://lcbp3.np-dms.work,http://localhost:7000,http://192.168.20.248:7000 | ||||
|  | ||||
|  | ||||
| NEXT_TELEMETRY_DISABLED=1 | ||||
|  | ||||
| PMA_HOST=mariadb | ||||
| PMA_PORT=3306 | ||||
| PMA_ABSOLUTE_URI=https://lcbp3.np-dms.work.com/pma/ | ||||
|  | ||||
| UPLOAD_LIMIT=256M | ||||
| MEMORY_LIMIT=512M | ||||
|  | ||||
| NGINX_SERVER_NAME=np-dms.work.com | ||||
| NGINX_PROXY_READ_TIMEOUT=300 | ||||
|  | ||||
| # QNAP_SSL_CERT_HOST=/etc/qnap-ssl/combine | ||||
| # QNAP_SSL_KEY_HOST=/etc/qnap-ssl/key | ||||
| # NGINX_SSL_CERT=/etc/nginx/certs/fullchain.pem | ||||
| # NGINX_SSL_KEY=/etc/nginx/certs/privkey.pem | ||||
| # NGINX_SSL_KEY=/etc/nginx/certs | ||||
| QNAP_SSL_CERT=/etc/config/QcloudSSLCertificate/cert | ||||
| NGINX_SSL_CERT=/etc/qnap-ssl | ||||
|  | ||||
| N8N_BASIC_AUTH_ACTIVE=true | ||||
| N8N_BASIC_AUTH_USER=n8n | ||||
| N8N_BASIC_AUTH_PASSWORD=Center#2025 | ||||
| N8N_PATH=/n8n/ | ||||
| N8N_PROTOCOL=https | ||||
| N8N_PROXY_HOPS=1 | ||||
| N8N_SECURE_COOKIE=true | ||||
| N8N_HOST=dcs.mycloudnas.com | ||||
| N8N_PORT=5678 | ||||
| N8N_EDITOR_BASE_URL=https://lcbp3.np-dms.work/n8n/ | ||||
| WEBHOOK_URL=https://lcbp3.np-dms.work/n8n/ | ||||
| N8N_ENCRYPTION_KEY=9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI | ||||
| # --- n8n → MariaDB --- | ||||
| # DB_TYPE=mysqldb | ||||
| # DB_MYSQLDB_HOST=mariadb | ||||
| # DB_MYSQLDB_PORT=3306 | ||||
| # DB_MYSQLDB_DATABASE=n8n | ||||
| # DB_MYSQLDB_USER=n8n_user | ||||
| # DB_MYSQLDB_PASSWORD=Center#2025   # เปลี่ยนเป็นรหัสแข็งแรงของคุณ | ||||
|  | ||||
| # ==== n8n → PostgreSQL (แทน MariaDB/MySQL) ==== | ||||
| DB_TYPE=postgresdb | ||||
| DB_POSTGRESDB_HOST=postgres_n8n | ||||
| DB_POSTGRESDB_PORT=5432 | ||||
| DB_POSTGRESDB_DATABASE=n8n | ||||
| DB_POSTGRESDB_USER=n8n | ||||
| DB_POSTGRESDB_PASSWORD=Center#2025 | ||||
| # path โฟลเดอร์ n8n เดิม (มี database.sqlite) | ||||
| # HOST_N8N=/share/Container/dms/n8n | ||||
|  | ||||
|  | ||||
| HOST_BASE=/share/Container/dms | ||||
| HOST_MARIADB=${HOST_BASE}/mariadb | ||||
| HOST_BACKEND=${HOST_BASE}/backend | ||||
| HOST_FRONTEND=${HOST_BASE}/frontend | ||||
| HOST_PHPMYADMIN=${HOST_BASE}/phpmyadmin | ||||
| HOST_NGINX=${HOST_BASE}/nginx | ||||
| HOST_LOGS=${HOST_BASE}/logs | ||||
| HOST_SCRIPTS=${HOST_BASE}/scripts | ||||
| HOST_N8N=/share/Container/dms/n8n | ||||
| HOST_N8N_CACHE=${HOST_BASE}/n8n-cache | ||||
| HOST_DATA=/share/dms-data | ||||
| # BACKEND_LOG_DIR=${HOST_LOGS}/backend | ||||
| BACKEND_LOG_DIR=/app/logs | ||||
							
								
								
									
										29
									
								
								backend/.backup/backend build.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								backend/.backup/backend build.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| # Backend build | ||||
|  | ||||
| ## วิธีสร้าง package-lock.json ด้วย Docker | ||||
|  | ||||
| ### 1. เช็ค uid:gid ของโฟลเดอร์โปรเจกต์บน QNAP | ||||
|  | ||||
| stat -c "%u:%g" . | ||||
|  | ||||
| ### 2. ใช้ค่าที่ได้มาแทน UID:GID | ||||
|  | ||||
| ```bash | ||||
| docker run --rm \ | ||||
|   -v "/share/Container/dms/frontend:/app" -w /app \ | ||||
|   --user UID:GID -e HOME=/tmp \ | ||||
|   node:20-alpine sh -lc 'mkdir -p /tmp && npm install --package-lock-only --ignore-scripts' | ||||
| ``` | ||||
|  | ||||
| สร้าง package-lock.json โดย ไม่ติดตั้ง node_modules | ||||
|  | ||||
| --user $(id -u):$(id -g) ทำให้ไฟล์ที่ได้เป็นเจ้าของโดยยูสเซอร์ปัจจุบัน (กันปัญหา root-owned) | ||||
|  | ||||
| ## ขั้นตอน Build บน QNAP | ||||
|  | ||||
| docker compose -f docker-backend-build.yml build --no-cache 2>&1 | tee backend_build.log | ||||
|  | ||||
| ## สำหรับ build local | ||||
|  | ||||
| cd backend | ||||
| docker build -t dms-backend:dev --target dev . | ||||
							
								
								
									
										
											BIN
										
									
								
								backend/.backup/backend_tree.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								backend/.backup/backend_tree.txt
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										0
									
								
								backend/ed25519 → backend/.backup/ed25519
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										0
									
								
								backend/ed25519 → backend/.backup/ed25519
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
								
								
									
										0
									
								
								backend/ed25519.pub → backend/.backup/ed25519.pub
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										0
									
								
								backend/ed25519.pub → backend/.backup/ed25519.pub
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
								
								
									
										64
									
								
								backend/.backup/fix-bearer-index.patch.diff
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								backend/.backup/fix-bearer-index.patch.diff
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| diff --git a/src/index.js b/src/index.js | ||||
| --- a/src/index.js | ||||
| +++ b/src/index.js | ||||
| @@ -1,9 +1,8 @@ | ||||
|  import fs from "node:fs"; | ||||
|  import path from "node:path"; | ||||
|  import express from "express"; | ||||
| -import cookieParser from "cookie-parser"; | ||||
|  import cors from "cors"; | ||||
|   | ||||
|  import sql from "./db/index.js"; | ||||
|  import healthRouter from "./routes/health.js"; | ||||
|  import { authJwt } from "./middleware/authJwt.js"; | ||||
| @@ -64,7 +63,7 @@ | ||||
|  // ✅ อยู่หลัง NPM/Reverse proxy → ให้ trust proxy เพื่อให้ cookie secure / proto ทำงานถูก | ||||
|  app.set("trust proxy", 1); | ||||
|   | ||||
| -// CORS แบบกำหนด origin ตามรายการที่อนุญาต + อนุญาต credentials (จำเป็นสำหรับ cookie) | ||||
| +// ✅ CORS สำหรับ Bearer token: ไม่ต้องใช้ credentials (ไม่มีคุกกี้) | ||||
|  app.use( | ||||
|    cors({ | ||||
|      origin(origin, cb) { | ||||
|        if (!origin) return cb(null, true); // server-to-server / curl | ||||
|        return cb(null, ALLOW_ORIGINS.includes(origin)); | ||||
|      }, | ||||
| -    credentials: true, | ||||
| +    credentials: false, | ||||
|      methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], | ||||
| -    allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"], | ||||
| +    allowedHeaders: [ | ||||
| +      "Content-Type", | ||||
| +      "Authorization", | ||||
| +      "X-Requested-With", | ||||
| +      "Accept", | ||||
| +      "Origin", | ||||
| +      "Referer", | ||||
| +      "User-Agent", | ||||
| +      "Cache-Control", | ||||
| +      "Pragma" | ||||
| +    ], | ||||
|      exposedHeaders: ["Content-Disposition", "Content-Length"], | ||||
|    }) | ||||
|  ); | ||||
|  // preflight | ||||
|  app.options( | ||||
|    "*", | ||||
|    cors({ | ||||
|      origin(origin, cb) { | ||||
|        if (!origin) return cb(null, true); | ||||
|        return cb(null, ALLOW_ORIGINS.includes(origin)); | ||||
|      }, | ||||
| -    credentials: true, | ||||
| +    credentials: false, | ||||
|    }) | ||||
|  ); | ||||
|   | ||||
| -app.use(cookieParser()); | ||||
| +// ❌ ไม่ต้อง parse cookie แล้ว (เราไม่ใช้คุกกี้สำหรับ auth) | ||||
| +// app.use(cookieParser()); | ||||
|   | ||||
|  // Payload limits | ||||
|  app.use(express.json({ limit: "10mb" })); | ||||
|  app.use(express.urlencoded({ extended: true, limit: "10mb" })); | ||||
|   | ||||
| @@ -1,8 +1,11 @@ | ||||
| .git | ||||
| .vscode | ||||
| .backup | ||||
| node_modules | ||||
| logs | ||||
| *.log | ||||
| Dockerfile* | ||||
| Dockerfile*.* | ||||
| *.yml | ||||
| README*.md | ||||
| coverage | ||||
| tmp | ||||
|   | ||||
							
								
								
									
										15
									
								
								backend/.eslintrc.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								backend/.eslintrc.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| { | ||||
|   "env": { | ||||
|     "node": true, | ||||
|     "es2021": true, | ||||
|     "jest": true | ||||
|   }, | ||||
|   "extends": ["eslint:recommended", "plugin:prettier/recommended"], | ||||
|   "parserOptions": { | ||||
|     "ecmaVersion": "latest", | ||||
|     "sourceType": "module" | ||||
|   }, | ||||
|   "rules": { | ||||
|     "prettier/prettier": "warn" | ||||
|   } | ||||
| } | ||||
							
								
								
									
										3
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
|  | ||||
| # Ignore Nginx Proxy Manager data | ||||
| /npm/ | ||||
							
								
								
									
										7
									
								
								backend/.prettierrc.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								backend/.prettierrc.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| { | ||||
|   "semi": true, | ||||
|   "singleQuote": true, | ||||
|   "trailingComma": "es5", | ||||
|   "arrowParens": "always", | ||||
|   "printWidth": 80 | ||||
| } | ||||
| @@ -67,3 +67,4 @@ EXPOSE 3001 | ||||
| HEALTHCHECK --interval=30s --timeout=5s --retries=10 \ | ||||
|   CMD wget -qO- http://127.0.0.1:3001/health || exit 1 | ||||
| CMD ["node","src/index.js"] | ||||
|  | ||||
|   | ||||
							
								
								
									
										159
									
								
								backend/README2.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								backend/README2.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,159 @@ | ||||
| # DMS Backend – คู่มือ Auth & RBAC/ABAC | ||||
|  | ||||
| ## ภาพรวมระบบ | ||||
| Backend ใช้ **Bearer Token** สำหรับการยืนยันตัวตน และตรวจสอบสิทธิ์ด้วย **RBAC (Role-Based Access Control)** ร่วมกับ **ABAC (Attribute-Based Access Control)** | ||||
|  | ||||
| โครงหลักคือ: | ||||
| 1. **authJwt()** → ตรวจสอบ JWT ใน header `Authorization: Bearer ...` | ||||
| 2. **loadPrincipalMw()** → โหลดข้อมูลผู้ใช้ + บทบาท + สิทธิ์ + ขอบเขตโปรเจ็ค/องค์กร | ||||
| 3. **requirePerm()** → ตรวจสอบ `perm_code` จากตาราง `permissions` และบังคับ ABAC (ORG/PROJECT scope) | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## การยืนยันตัวตน (Authentication) | ||||
|  | ||||
| ### Frontend ส่งอย่างไร | ||||
| ```http | ||||
| GET /api/projects | ||||
| Authorization: Bearer <access_token> | ||||
| ``` | ||||
|  | ||||
| - **ไม่มีการใช้ cookie** (Bearer-only) | ||||
| - ถ้า token หมดอายุ ให้ใช้ `refresh_token` ไปขอใหม่ที่ `/api/auth/refresh` | ||||
|  | ||||
| ### Middleware `authJwt()` | ||||
| - อ่าน `Authorization: Bearer ...` | ||||
| - ตรวจสอบด้วย `JWT_SECRET` | ||||
| - เติม `req.auth = { user_id, username }` | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## การโหลด Principal | ||||
|  | ||||
| ### Middleware `loadPrincipalMw()` | ||||
| - ใช้ `user_id` ไป query DB: | ||||
|   - users, roles, permissions, project_ids, org_ids | ||||
| - สร้าง `req.principal`: | ||||
| ```js | ||||
| { | ||||
|   user_id, username, email, first_name, last_name, org_id, | ||||
|   roles: [{ role_id, role_code, role_name }], | ||||
|   permissions: Set<perm_code>, | ||||
|   project_ids: [..], | ||||
|   org_ids: [..], | ||||
|   is_superadmin: true/false, | ||||
|  | ||||
|   // helper functions | ||||
|   can(code), | ||||
|   canAny(codes[]), | ||||
|   canAll(codes[]), | ||||
|   inProject(pid), | ||||
|   inOrg(oid) | ||||
| } | ||||
| ``` | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## การตรวจสอบสิทธิ์ (RBAC + ABAC) | ||||
|  | ||||
| ### Middleware `requirePerm(permCode, { projectParam?, orgParam? })` | ||||
| 1. ตรวจว่า user มี `permCode` หรือเป็น superadmin | ||||
| 2. อ่าน `scope_level` จากตาราง `permissions` | ||||
|    - `GLOBAL`   → มีสิทธิ์ก็พอ | ||||
|    - `ORG`      → ต้องมีสิทธิ์ + อยู่ใน org scope | ||||
|    - `PROJECT`  → ต้องมีสิทธิ์ + อยู่ใน project scope | ||||
| 3. อ่าน `project_id` / `org_id` จาก request (`params`, `query`, `body`) | ||||
| 4. ถ้าไม่ผ่าน → คืน `403 FORBIDDEN` | ||||
|  | ||||
| ### Error response ตัวอย่าง | ||||
| ```json | ||||
| { "error": "FORBIDDEN", "need": "projects.manage" } | ||||
| { "error": "FORBIDDEN_PROJECT", "project_id": 12 } | ||||
| { "error": "FORBIDDEN_ORG", "org_id": 5 } | ||||
| ``` | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## รูปแบบที่แนะนำ | ||||
|  | ||||
| ### List (PROJECT scope) | ||||
| ```js | ||||
| r.get("/", requirePerm("documents.view", { projectParam: "project_id" }), async (req, res) => { | ||||
|   const P = req.principal; | ||||
|   const { project_id } = req.query; | ||||
|   const cond = [], params = []; | ||||
|  | ||||
|   if (!P.is_superadmin) { | ||||
|     if (project_id) { | ||||
|       if (!P.inProject(+project_id)) return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||
|       cond.push("project_id=?"); params.push(+project_id); | ||||
|     } else if (P.project_ids?.length) { | ||||
|       cond.push(`project_id IN (${P.project_ids.map(()=>"?").join(",")})`); | ||||
|       params.push(...P.project_ids); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; | ||||
|   const [rows] = await sql.query(`SELECT * FROM documents ${where} ORDER BY created_at DESC LIMIT 50`, params); | ||||
|   res.json(rows); | ||||
| }); | ||||
| ``` | ||||
|  | ||||
| ### Item (PROJECT scope) | ||||
| ```js | ||||
| r.delete("/:id", requirePerm("drawings.delete"), async (req, res) => { | ||||
|   const id = +req.params.id; | ||||
|   const [[row]] = await sql.query("SELECT project_id FROM drawings WHERE id=?", [id]); | ||||
|   if (!row) return res.status(404).json({ error: "Not found" }); | ||||
|   if (!req.principal.is_superadmin && !req.principal.inProject(row.project_id)) { | ||||
|     return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||
|   } | ||||
|   await sql.query("DELETE FROM drawings WHERE id=?", [id]); | ||||
|   res.json({ ok: 1 }); | ||||
| }); | ||||
| ``` | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## การแมปสิทธิ์ (perm_code) | ||||
|  | ||||
| | หมวด         | สิทธิ์ (perm_code)                          | scope      | | ||||
| |--------------|---------------------------------------------|------------| | ||||
| | Organizations| `organizations.view`, `organizations.manage`| GLOBAL     | | ||||
| | Projects     | `projects.view`, `projects.manage`          | ORG        | | ||||
| | Drawings     | `drawings.view`, `drawings.upload`, `drawings.delete` | PROJECT | | ||||
| | Documents    | `documents.view`, `documents.manage`        | PROJECT    | | ||||
| | RFAs         | `rfas.view`, `rfas.create`, `rfas.respond`, `rfas.delete` | PROJECT | | ||||
| | Correspondences | `corr.view`, `corr.manage`               | PROJECT    | | ||||
| | Transmittals | `transmittals.manage`                       | PROJECT    | | ||||
| | Reports      | `reports.view`                              | GLOBAL     | | ||||
| | Settings     | `settings.manage`                           | GLOBAL     | | ||||
| | Admin        | `admin.access`                              | ORG        | | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Checklist สำหรับเพิ่ม Endpoint ใหม่ | ||||
|  | ||||
| 1. เลือก `perm_code` ที่ตรงกับ seed | ||||
| 2. ใส่ `requirePerm("<perm>", { projectParam?: "...", orgParam?: "..." })` | ||||
| 3. ถ้าเป็น GET/PUT/DELETE record เดี่ยว → ตรวจสอบซ้ำด้วย `inProject`/`inOrg` | ||||
| 4. ใช้ `callProc("sp_name", [...])` ถ้า endpoint เรียก Stored Procedure | ||||
| 5. ฝั่ง FE ต้องส่ง `Authorization: Bearer ...` และ parameter `project_id`/`org_id` ที่จำเป็น | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Pitfalls ที่พบบ่อย | ||||
| - ลืมส่ง `project_id` ในคำขอ → 403 | ||||
| - อ้าง perm_code ผิด (เช่น `document.view` แทน `documents.view`) | ||||
| - ไม่กรอง project/org scope ใน query → ข้อมูลรั่ว | ||||
| - ลืมเช็ค item-level ABAC → ข้ามขอบเขตได้ | ||||
| - ปน cookie-auth เข้ามา → backend จะไม่รองรับแล้ว | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## TL;DR | ||||
| - ทุกคำขอ → Bearer Token   | ||||
| - `authJwt()` → ใส่ `req.auth`   | ||||
| - `loadPrincipalMw()` → ใส่ `req.principal` (roles, perms, scope)   | ||||
| - `requirePerm()` → บังคับ RBAC + ABAC อัตโนมัติ   | ||||
| - เพิ่ม endpoint ใหม่ → ใช้ checklist ข้างบน | ||||
							
								
								
									
										77
									
								
								backend/docker-compose.yml
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										77
									
								
								backend/docker-compose.yml
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| # File: backend/docker-compose.yml | ||||
| # DMS Container v0_8_0 แยก service/ lcbp3-backend | ||||
| x-restart: &restart_policy | ||||
|   restart: unless-stopped | ||||
|  | ||||
| x-logging: &default_logging | ||||
|   logging: | ||||
|     driver: "json-file" | ||||
|     options: | ||||
|       max-size: "10m" | ||||
|       max-file: "5" | ||||
| volumes: | ||||
|   backend_node_modules: | ||||
| services: | ||||
|   backend: | ||||
|     <<: [*restart_policy, *default_logging] | ||||
|     image: dms-backend:dev | ||||
|     # pull_policy: never  # <-- FINAL FIX ADDED HERE | ||||
|     container_name: dms_backend | ||||
|     stdin_open: true | ||||
|     tty: true | ||||
|     #user: "node" | ||||
|     user: "1000:1000" | ||||
|     working_dir: /app | ||||
|     deploy: | ||||
|       resources: | ||||
|         limits: | ||||
|           cpus: "2.0" | ||||
|           memory: 1G | ||||
|         reservations: | ||||
|           cpus: "0.25" | ||||
|           memory: 256M | ||||
|     environment: | ||||
|       TZ: "Asia/Bangkok" | ||||
|       CHOKIDAR_USEPOLLING: "1" | ||||
|       CHOKIDAR_INTERVAL: "300" | ||||
|       WATCHPACK_POLLING: "true" | ||||
|       # NODE_ENV: "production" | ||||
|       NODE_ENV: "development" | ||||
|       PORT: "3001" | ||||
|       DB_HOST: "mariadb" | ||||
|       DB_PORT: "3306" | ||||
|       DB_USER: "center" | ||||
|       DB_PASSWORD: "Center#2025" | ||||
|       DB_NAME: "dms" | ||||
|       JWT_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca" | ||||
|       JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca" | ||||
|       JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c" | ||||
|       ACCESS_TTL_MS: "900000" | ||||
|       REFRESH_TTL_MS: "604800000" | ||||
|       JWT_EXPIRES_IN: "12h" | ||||
|       PASSWORD_SALT_ROUNDS: "10" | ||||
|       FRONTEND_ORIGIN: "https://lcbp3.np-dms.work" | ||||
|       CORS_ORIGINS: "https://backend.np-dms.work,http://localhost:3000,http://127.0.0.1:3000,https://lcbp3.np-dms.work" | ||||
|       COOKIE_DOMAIN: ".np-dms.work" | ||||
|       RATE_LIMIT_WINDOW_MS: "900000" | ||||
|       RATE_LIMIT_MAX: "200" | ||||
|       BACKEND_LOG_DIR: "/app/logs" | ||||
|     networks: | ||||
|       lcbp3: {} | ||||
|     volumes: | ||||
|       - "/share/Container/dms/backend/src:/app/src:rw" | ||||
|       # - "/share/Container/dms/backend/package.json:/app/package.json" | ||||
|       # - "/share/Container/dms/backend/package-lock.json:/app/package-lock.json" | ||||
|       - "/share/dms-data:/share/dms-data:rw" | ||||
|       - "/share/Container/dms/logs/backend:/app/logs:rw" | ||||
|       # - "/share/Container/dms/backend/node_modules:/app/node_modules" | ||||
|       - "backend_node_modules:/app/node_modules" | ||||
|     healthcheck: | ||||
|       test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3001/health"] | ||||
|       interval: 10s | ||||
|       timeout: 5s | ||||
|       retries: 30 | ||||
|  | ||||
| networks: | ||||
|   lcbp3: | ||||
|     external: true | ||||
							
								
								
									
										6069
									
								
								backend/package-lock.json
									
									
									
										generated
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										6069
									
								
								backend/package-lock.json
									
									
									
										generated
									
									
									
										
										
										Executable file → Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,22 +1,25 @@ | ||||
| { | ||||
|   "name": "dms-backend", | ||||
|   "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" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,25 +0,0 @@ | ||||
|  | ||||
| { | ||||
|   "name": "dms-backend", | ||||
|   "version": "0.5.0", | ||||
|   "private": true, | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "dev": "node --env-file=../.env src/index.js", | ||||
|     "start": "node src/index.js", | ||||
|     "health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\"" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "bcrypt": "5.1.1", | ||||
|     "cors": "2.8.5", | ||||
|     "dotenv": "16.4.5", | ||||
|     "express": "4.19.2", | ||||
|     "express-rate-limit": "7.4.0", | ||||
|     "helmet": "7.1.0", | ||||
|     "jsonwebtoken": "9.0.2", | ||||
|     "mariadb": "3.3.1", | ||||
|     "morgan": "1.10.0", | ||||
|     "sequelize": "6.37.3" | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -1,38 +0,0 @@ | ||||
| { | ||||
|   "name": "dms-backend", | ||||
|   "version": "0.6.0", | ||||
|   "private": true, | ||||
|   "type": "module", | ||||
|   "main": "src/index.js", | ||||
|   "engines": { | ||||
|     "node": ">=20.0.0" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "dev": "nodemon --watch src src/index.js", | ||||
|     "start": "node src/index.js", | ||||
|     "lint": "echo 'lint placeholder'", | ||||
|     "health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\"", | ||||
|     "postinstall": "node -e \"console.log('Installed dms-backend %s','0.6.0')\"" | ||||
|   }, | ||||
|  | ||||
|   "dependencies": { | ||||
|     "bcrypt": "5.1.1", | ||||
|     "bcryptjs": "^2.4.3", | ||||
|     "cookie-parser": "^1.4.7", | ||||
|     "cors": "2.8.5", | ||||
|     "dotenv": "16.4.5", | ||||
|     "express": "^4.21.2", | ||||
|     "express-rate-limit": "7.4.0", | ||||
|     "helmet": "7.1.0", | ||||
|     "jsonwebtoken": "9.0.2", | ||||
|     "mariadb": "3.3.1", | ||||
|     "morgan": "^1.10.1", | ||||
|     "multer": "^2.0.2", | ||||
|     "mysql2": "^3.11.0", | ||||
|     "sequelize": "6.37.3", | ||||
|     "winston": "^3.13.0" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "nodemon": "^3.1.10" | ||||
|   } | ||||
| } | ||||
| @@ -1,24 +1,45 @@ | ||||
| export const config = { | ||||
|   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; | ||||
|   | ||||
| @@ -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; | ||||
							
								
								
									
										39
									
								
								backend/src/db/index copy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								backend/src/db/index copy.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| // FILE: backend/src/db/index.js (ESM) | ||||
| import mysql from "mysql2/promise"; | ||||
|  | ||||
| const { | ||||
|   DB_HOST = "mariadb", | ||||
|   DB_PORT = "3306", | ||||
|   DB_USER = "center", | ||||
|   DB_PASSWORD = "Center#2025", | ||||
|   DB_NAME = "dms", | ||||
|   DB_CONN_LIMIT = "10", | ||||
| } = process.env; | ||||
|  | ||||
| const pool = mysql.createPool({ | ||||
|   host: DB_HOST, | ||||
|   port: Number(DB_PORT), | ||||
|   user: DB_USER, | ||||
|   password: DB_PASSWORD, | ||||
|   database: DB_NAME, | ||||
|   connectionLimit: Number(DB_CONN_LIMIT), | ||||
|   waitForConnections: true, | ||||
|   namedPlaceholders: true, | ||||
|   dateStrings: true, // คงวันที่เป็น string | ||||
|   timezone: "Z", // ใช้ UTC | ||||
| }); | ||||
|  | ||||
| /** | ||||
|  * เรียก Stored Procedure แบบง่าย | ||||
|  * @param {string} procName ชื่อโปรซีเยอร์ เช่น "sp_rfa_create_with_items" | ||||
|  * @param {Array<any>} params ลำดับพารามิเตอร์ | ||||
|  * @returns {Promise<any>} rows จาก CALL | ||||
|  */ | ||||
| export async function callProc(procName, params = []) { | ||||
|   const placeholders = params.map(() => "?").join(","); | ||||
|   const sql = `CALL ${procName}(${placeholders})`; | ||||
|   const [rows] = await pool.query(sql, params); | ||||
|   return rows; | ||||
| } | ||||
|  | ||||
| export default pool; // ใช้ sql.query(...) ได้ตามที่ routes เรียกอยู่ | ||||
| @@ -1,13 +1,13 @@ | ||||
| // ESM | ||||
| 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 เรียกอยู่ | ||||
|   | ||||
							
								
								
									
										71
									
								
								backend/src/db/sequelize copy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								backend/src/db/sequelize copy.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| // FILE: backend/src/db/sequelize.js | ||||
| // “lazy-load” ตาม env ปลอดภัยกว่า และยังคง dbReady() ให้เรียกทดสอบได้ | ||||
| // ใช้ได้เมื่อจำเป็น (เช่น งาน admin tool เฉพาะกิจ) | ||||
| // ตั้ง ENABLE_SEQUELIZE=1 เพื่อเปิดใช้ Model loader; ไม่งั้นจะเป็นโหมดเบา ๆ | ||||
| import { Sequelize } from "sequelize"; | ||||
| import { config } from "../config.js"; | ||||
|  | ||||
| export const sequelize = new Sequelize( | ||||
|   config.DB.NAME, | ||||
|   config.DB.USER, | ||||
|   config.DB.PASS, | ||||
|   { | ||||
|     host: config.DB.HOST, | ||||
|     port: config.DB.PORT, | ||||
|     dialect: "mariadb", | ||||
|     logging: false, | ||||
|     dialectOptions: { timezone: "Z" }, | ||||
|     define: { freezeTableName: true, underscored: false, timestamps: false }, | ||||
|     pool: { max: 10, min: 0, idle: 10000 }, | ||||
|   } | ||||
| ); | ||||
|  | ||||
| export let User = null; | ||||
| export let Role = null; | ||||
| export let Permission = null; | ||||
| export let UserRole = null; | ||||
| export let RolePermission = null; | ||||
|  | ||||
| if (process.env.ENABLE_SEQUELIZE === "1") { | ||||
|   // โหลดโมเดลแบบ on-demand เพื่อลดความเสี่ยง runtime หากไฟล์โมเดลไม่มี | ||||
|   const mdlUser = await import("./models/User.js").catch(() => null); | ||||
|   const mdlRole = await import("./models/Role.js").catch(() => null); | ||||
|   const mdlPerm = await import("./models/Permission.js").catch(() => null); | ||||
|   const mdlUR = await import("./models/UserRole.js").catch(() => null); | ||||
|   const mdlRP = await import("./models/RolePermission.js").catch(() => null); | ||||
|  | ||||
|   if (mdlUser?.default) User = mdlUser.default(sequelize); | ||||
|   if (mdlRole?.default) Role = mdlRole.default(sequelize); | ||||
|   if (mdlPerm?.default) Permission = mdlPerm.default(sequelize); | ||||
|   if (mdlUR?.default) UserRole = mdlUR.default(sequelize); | ||||
|   if (mdlRP?.default) RolePermission = mdlRP.default(sequelize); | ||||
|  | ||||
|   if (User && Role && Permission && UserRole && RolePermission) { | ||||
|     User.belongsToMany(Role, { | ||||
|       through: UserRole, | ||||
|       foreignKey: "user_id", | ||||
|       otherKey: "role_id", | ||||
|     }); | ||||
|     Role.belongsToMany(User, { | ||||
|       through: UserRole, | ||||
|       foreignKey: "role_id", | ||||
|       otherKey: "user_id", | ||||
|     }); | ||||
|  | ||||
|     Role.belongsToMany(Permission, { | ||||
|       through: RolePermission, | ||||
|       foreignKey: "role_id", | ||||
|       otherKey: "permission_id", | ||||
|     }); | ||||
|     Permission.belongsToMany(Role, { | ||||
|       through: RolePermission, | ||||
|       foreignKey: "permission_id", | ||||
|       otherKey: "role_id", | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export async function dbReady() { | ||||
|   // โหมดเบา ๆ: แค่ทดสอบเชื่อมต่อ | ||||
|   await sequelize.authenticate(); | ||||
| } | ||||
							
								
								
									
										74
									
								
								backend/src/db/sequelize.js
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						
									
										74
									
								
								backend/src/db/sequelize.js
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							| @@ -1,5 +1,9 @@ | ||||
| import { Sequelize } from 'sequelize'; | ||||
| 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(); | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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(); | ||||
|   }; | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										61
									
								
								backend/src/middleware/auth copy.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										61
									
								
								backend/src/middleware/auth copy.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| // FILE: backend/src/middleware/auth.js | ||||
|  | ||||
| import jwt from "jsonwebtoken"; | ||||
| import { config } from "../config.js"; | ||||
| import { User, Role, UserRole } from "../db/sequelize.js"; | ||||
|  | ||||
| export function signAccessToken(payload) { | ||||
|   return jwt.sign(payload, config.JWT.SECRET, { | ||||
|     expiresIn: config.JWT.EXPIRES_IN, | ||||
|   }); | ||||
| } | ||||
| export function signRefreshToken(payload) { | ||||
|   return jwt.sign(payload, config.JWT.REFRESH_SECRET, { | ||||
|     expiresIn: config.JWT.REFRESH_EXPIRES_IN, | ||||
|   }); | ||||
| } | ||||
|  | ||||
| export function extractToken(req) { | ||||
|   // ให้คุกกี้มาก่อน แล้วค่อย Bearer (รองรับทั้งสองทาง) | ||||
|   const cookieTok = req.cookies?.access_token || null; | ||||
|   if (cookieTok) return cookieTok; | ||||
|   const hdr = req.headers.authorization || ""; | ||||
|   return hdr.startsWith("Bearer ") ? hdr.slice(7) : null; | ||||
| } | ||||
|  | ||||
| export function requireAuth(req, res, next) { | ||||
|   if (req.path === "/health") return next(); // อนุญาต health เสมอ | ||||
|   const token = extractToken(req); | ||||
|   if (!token) return res.status(401).json({ error: "Missing token" }); | ||||
|  | ||||
|   try { | ||||
|     req.user = jwt.verify(token, config.JWT.SECRET); | ||||
|     next(); | ||||
|   } catch { | ||||
|     return res.status(401).json({ error: "Invalid/Expired token" }); | ||||
|   } | ||||
| } | ||||
| // ใช้กับเส้นทางที่ login แล้วจะ enrich ต่อได้ แต่ไม่บังคับ | ||||
| export function optionalAuth(req, _res, next) { | ||||
|   const token = extractToken(req); | ||||
|   if (!token) return next(); | ||||
|   try { | ||||
|     req.user = jwt.verify(token, config.JWT.SECRET); | ||||
|   } catch {} | ||||
|   next(); | ||||
| } | ||||
|  | ||||
| export async function enrichRoles(req, _res, next) { | ||||
|   if (!req.user?.user_id) return next(); | ||||
|   const rows = await UserRole.findAll({ | ||||
|     where: { user_id: req.user.user_id }, | ||||
|     include: [{ model: Role }], | ||||
|   }).catch(() => []); | ||||
|   req.user.roles = rows.map((r) => r.role?.role_name).filter(Boolean); | ||||
|   next(); | ||||
| } | ||||
|  | ||||
| export function hasPerm(req, perm) { | ||||
|   const set = new Set(req?.user?.permissions || []); | ||||
|   return set.has(perm); | ||||
| } | ||||
							
								
								
									
										43
									
								
								backend/src/middleware/auth.js
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						
									
										43
									
								
								backend/src/middleware/auth.js
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							| @@ -1,37 +1,30 @@ | ||||
| import jwt from 'jsonwebtoken'; | ||||
| 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); | ||||
| } | ||||
| @@ -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" }); | ||||
|     } | ||||
|   }; | ||||
| } | ||||
|   | ||||
| @@ -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
									
								
							
							
						
						
									
										39
									
								
								backend/src/middleware/index.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| // File: backend/src/middleware/index.js | ||||
| import * as authJwt from "./authJwt.js"; | ||||
| import * as abac from "./abac.js"; | ||||
| import * as auth from "./auth.js"; | ||||
| import * as errorHandler from "./errorHandler.js"; | ||||
| import * as loadPrincipal from "./loadPrincipal.js"; | ||||
| import * as permGuard from "./permGuard.js"; | ||||
| import * as permissions from "./permissions.js"; | ||||
| import * as rbac from "./rbac.js"; | ||||
| import * as requirePerm from "./requirePerm.js"; | ||||
|  | ||||
| // Export ทุกอย่างออกมาเป็น named exports | ||||
| // เพื่อให้สามารถ import แบบ `import { authJwt, permGuard } from '../middleware';` ได้ | ||||
| export { | ||||
|   authJwt, | ||||
|   abac, | ||||
|   auth, | ||||
|   errorHandler, | ||||
|   loadPrincipal, | ||||
|   permGuard, | ||||
|   permissions, | ||||
|   rbac, | ||||
|   requirePerm, | ||||
| }; | ||||
|  | ||||
| // (Optional) สร้าง default export สำหรับกรณีที่ต้องการ import ทั้งหมดใน object เดียว | ||||
| const middleware = { | ||||
|   authJwt, | ||||
|   abac, | ||||
|   auth, | ||||
|   errorHandler, | ||||
|   loadPrincipal, | ||||
|   permGuard, | ||||
|   permissions, | ||||
|   rbac, | ||||
|   requirePerm, | ||||
| }; | ||||
|  | ||||
| export default middleware; | ||||
							
								
								
									
										95
									
								
								backend/src/middleware/loadPrincipal.js
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						
									
										95
									
								
								backend/src/middleware/loadPrincipal.js
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							| @@ -1,15 +1,98 @@ | ||||
| // loadPrincipal.js | ||||
| 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" }); | ||||
|     } | ||||
|   }; | ||||
| } | ||||
|   | ||||
| @@ -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(); | ||||
|   }; | ||||
| } | ||||
| @@ -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(); | ||||
|   }; | ||||
|   | ||||
| @@ -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(); | ||||
|   }; | ||||
| } | ||||
|   | ||||
							
								
								
									
										18
									
								
								backend/src/middleware/requireBearer.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										18
									
								
								backend/src/middleware/requireBearer.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| // FILE: src/middleware/requireBearer.js | ||||
| import jwt from "jsonwebtoken"; | ||||
| import { findUserById } from "../db/models/users.js"; | ||||
|  | ||||
| export async function requireBearer(req, res, next) { | ||||
|   const hdr = req.get("Authorization") || ""; | ||||
|   const m = hdr.match(/^Bearer\s+(.+)$/i); | ||||
|   if (!m) return res.status(401).json({ error: "Unauthenticated" }); | ||||
|   try { | ||||
|     const payload = jwt.verify(m[1], process.env.JWT_ACCESS_SECRET, { issuer: "dms-backend" }); | ||||
|     const user = await findUserById(payload.user_id); | ||||
|     if (!user) return res.status(401).json({ error: "Unauthenticated" }); | ||||
|     req.user = { user_id: user.user_id, username: user.username, email: user.email, first_name: user.first_name, last_name: user.last_name }; | ||||
|     next(); | ||||
|   } catch { | ||||
|     return res.status(401).json({ error: "Unauthenticated" }); | ||||
|   } | ||||
| } | ||||
| @@ -1,25 +1,64 @@ | ||||
| // src/middleware/requirePerm.js | ||||
| 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(); | ||||
|   }; | ||||
| } | ||||
| @@ -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; | ||||
|   | ||||
							
								
								
									
										137
									
								
								backend/src/routes/auth พัง.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								backend/src/routes/auth พัง.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,137 @@ | ||||
| // backend/src/routes/auth.js | ||||
| import { Router } from "express"; | ||||
| import bcrypt from "bcryptjs"; | ||||
| import jwt from "jsonwebtoken"; | ||||
| import { config } from "../config.js"; | ||||
| import { User } from "../db/sequelize.js"; | ||||
|  | ||||
| import { | ||||
|   signAccessToken, | ||||
|   signRefreshToken, | ||||
|   requireAuth, | ||||
| } from "../middleware/auth.js"; | ||||
|  | ||||
| const r = Router(); | ||||
|  | ||||
| // cookie options — ทำในไฟล์นี้เลย (ไม่เพิ่ม utils ใหม่) | ||||
| function cookieOpts(maxAgeMs) { | ||||
|   const isProd = process.env.NODE_ENV === "production"; | ||||
|   const opts = { | ||||
|     httpOnly: true, | ||||
|     secure: true, // หลัง Nginx/HTTPS | ||||
|     sameSite: "none", // ส่งข้าม subdomain ได้ | ||||
|     path: "/", | ||||
|     maxAge: maxAgeMs, | ||||
|   }; | ||||
|   if (config.COOKIE_DOMAIN) opts.domain = config.COOKIE_DOMAIN; // เช่น .np-dms.work | ||||
|   if (!isProd && process.env.ALLOW_INSECURE_COOKIE === "1") { | ||||
|     opts.secure = false; | ||||
|     opts.sameSite = "lax"; | ||||
|   } | ||||
|   return opts; | ||||
| } | ||||
|  | ||||
| // helper TTL จาก config เดิม | ||||
| const ACCESS_TTL_MS = (() => { | ||||
|   // รับทั้งรูปแบบ "15m" (เช่น EXPIRES_IN) หรือ milliseconds | ||||
|   // ถ้าเป็นเลขอยู่แล้ว (ms) ก็ใช้เลย | ||||
|   if (/^\d+$/.test(String(config.JWT.EXPIRES_IN))) | ||||
|     return Number(config.JWT.EXPIRES_IN); | ||||
|   // แปลงรูปแบบเช่น "15m" เป็น ms แบบง่าย ๆ | ||||
|   const s = String(config.JWT.EXPIRES_IN || "15m"); | ||||
|   const n = parseInt(s, 10); | ||||
|   if (s.endsWith("h")) return n * 60 * 60 * 1000; | ||||
|   if (s.endsWith("m")) return n * 60 * 1000; | ||||
|   if (s.endsWith("s")) return n * 1000; | ||||
|   return 15 * 60 * 1000; | ||||
| })(); | ||||
| const REFRESH_TTL_MS = (() => { | ||||
|   if (/^\d+$/.test(String(config.JWT.REFRESH_EXPIRES_IN))) | ||||
|     return Number(config.JWT.REFRESH_EXPIRES_IN); | ||||
|   const s = String(config.JWT.REFRESH_EXPIRES_IN || "7d"); | ||||
|   const n = parseInt(s, 10); | ||||
|   if (s.endsWith("d")) return n * 24 * 60 * 60 * 1000; | ||||
|   if (s.endsWith("h")) return n * 60 * 60 * 1000; | ||||
|   if (s.endsWith("m")) return n * 60 * 1000; | ||||
|   if (s.endsWith("s")) return n * 1000; | ||||
|   return 7 * 24 * 60 * 60 * 1000; | ||||
| })(); | ||||
|  | ||||
| // == POST /api/auth/login == | ||||
| r.post("/login", async (req, res) => { | ||||
|   const { username, password } = req.body || {}; | ||||
|   if (!username || !password) | ||||
|     return res.status(400).json({ error: "USERNAME_PASSWORD_REQUIRED" }); | ||||
|  | ||||
|   const user = await User.findOne({ where: { username }, raw: true }); | ||||
|   if (!user) return res.status(401).json({ error: "INVALID_CREDENTIALS" }); | ||||
|  | ||||
|   const ok = await bcrypt.compare(password, user.password_hash || ""); | ||||
|   if (!ok) return res.status(401).json({ error: "INVALID_CREDENTIALS" }); | ||||
|  | ||||
|   // NOTE: สิทธิ์จริง ๆ ให้ดึงจากตาราง role/permission ของคุณ | ||||
|   const permissions = []; // ใส่เปล่าไว้ก่อน (คุณมี enrichRoles ที่อื่นอยู่แล้ว) | ||||
|   const payload = { | ||||
|     user_id: user.user_id, | ||||
|     username: user.username, | ||||
|     permissions, | ||||
|   }; | ||||
|  | ||||
|   const access = signAccessToken(payload); | ||||
|   const refresh = signRefreshToken({ user_id: user.user_id }); | ||||
|  | ||||
|   // ตั้งคุกกี้ (และยังส่ง token ใน body ได้ถ้าคุณใช้อยู่) | ||||
|   res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS)); | ||||
|   res.cookie("refresh_token", refresh, cookieOpts(REFRESH_TTL_MS)); | ||||
|  | ||||
|   return res.json({ | ||||
|     ok: true, | ||||
|     token: access, | ||||
|     refresh_token: refresh, | ||||
|     user: { user_id: user.user_id, username: user.username, email: user.email }, | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| // == GET /api/auth/me == | ||||
| r.get("/me", requireAuth, async (req, res) => { | ||||
|   // enrich เพิ่มจากฐานได้ตามต้องการ; ตอนนี้เอาเบื้องต้นจาก token | ||||
|   return res.json({ | ||||
|     ok: true, | ||||
|     user: { | ||||
|       user_id: req.user.user_id, | ||||
|       username: req.user.username, | ||||
|       permissions: req.user.permissions || [], | ||||
|     }, | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| // == POST /api/auth/refresh == | ||||
| r.post("/refresh", async (req, res) => { | ||||
|   // รับจากคุกกี้ก่อน แล้วค่อย Authorization | ||||
|   const bearer = req.headers.authorization?.startsWith("Bearer ") | ||||
|     ? req.headers.authorization.slice(7) | ||||
|     : null; | ||||
|   const rt = req.cookies?.refresh_token || bearer; | ||||
|   if (!rt) return res.status(401).json({ error: "Unauthenticated" }); | ||||
|  | ||||
|   try { | ||||
|     // verify refresh โดยตรงด้วย config.JWT.REFRESH_SECRET (คงสไตล์เดิม) | ||||
|     const p = jwt.verify(rt, config.JWT.REFRESH_SECRET, { clockTolerance: 10 }); | ||||
|     // โหลดสิทธิ์ล่าสุดจากฐานได้ ถ้าต้องการ; ตอนนี้ใส่ [] ไว้ก่อน | ||||
|     const permissions = []; | ||||
|     const access = signAccessToken({ user_id: p.user_id, permissions }); | ||||
|     res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS)); | ||||
|     return res.json({ ok: true, token: access }); | ||||
|   } catch { | ||||
|     return res.status(401).json({ error: "Unauthenticated" }); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| // == POST /api/auth/logout == | ||||
| r.post("/logout", (_req, res) => { | ||||
|   res.clearCookie("access_token", { path: "/" }); | ||||
|   res.clearCookie("refresh_token", { path: "/" }); | ||||
|   return res.json({ ok: true }); | ||||
| }); | ||||
|  | ||||
| export default r; | ||||
| @@ -1,57 +1,100 @@ | ||||
| // src/routes/auth.js (ESM) | ||||
| 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 แทน | ||||
|   | ||||
| @@ -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" }); | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
| } | ||||
| // 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] | ||||
|   ); | ||||
|  | ||||
|  | ||||
| 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' }), | ||||
| }); | ||||
| 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); | ||||
| const { cat_name } = req.body; | ||||
| await sql.query('UPDATE categories SET cat_name=? WHERE cat_id=?', [cat_name, id]); | ||||
|     await sql.query("DELETE FROM categories WHERE cat_id=?", [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) => { | ||||
| // Subcategories | ||||
| r.get("/subcategories", requirePerm("organizations.view"), 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); | ||||
|   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; | ||||
| @@ -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' }), | ||||
| // 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 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); | ||||
|     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 mapping | ||||
| r.get('/:id', | ||||
| requirePerm(PERM.contract_dwg.read, { scope: 'org', getOrgId: OWN.getOrgIdById }), | ||||
| async (req, res) => { | ||||
| // 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 [[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); | ||||
| } | ||||
| ); | ||||
| }); | ||||
|  | ||||
|  | ||||
| // 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 }), | ||||
| // 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; | ||||
| 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]); | ||||
|     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 }); | ||||
|   } | ||||
| ); | ||||
|  | ||||
|  | ||||
| // UPDATE | ||||
| r.put('/:id', | ||||
| requirePerm(PERM.contract_dwg.update, { scope: 'org', getOrgId: OWN.getOrgIdById }), | ||||
| async (req, res) => { | ||||
| r.put("/:id", requirePerm("drawings.upload"), 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 }); | ||||
| } | ||||
| ); | ||||
|   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) => { | ||||
| r.delete("/:id", requirePerm("drawings.delete"), async (req, res) => { | ||||
|   const id = Number(req.params.id); | ||||
| await sql.query('DELETE FROM contract_dwg WHERE id=?', [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; | ||||
| @@ -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'); | ||||
|  | ||||
|  | ||||
| r.get('/', | ||||
| requirePerm(PERM.contract.read, { scope: 'global' }), | ||||
| // 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 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); | ||||
|     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)); | ||||
|     } | ||||
|  | ||||
|     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 }), | ||||
| // 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 [[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 }), | ||||
| // CREATE | ||||
| r.post( | ||||
|   "/", | ||||
|   requirePerm("projects.manage", { orgParam: "org_id" }), | ||||
|   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]); | ||||
|     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 }), | ||||
| // UPDATE | ||||
| r.put( | ||||
|   "/:id", | ||||
|   requirePerm("projects.manage", { orgParam: "org_id" }), | ||||
|   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]); | ||||
|     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 }), | ||||
| // DELETE | ||||
| r.delete( | ||||
|   "/:id", | ||||
|   requirePerm("projects.manage", { orgParam: "org_id" }), | ||||
|   async (req, res) => { | ||||
|     const id = Number(req.params.id); | ||||
| await sql.query('DELETE FROM contracts WHERE id=?', [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; | ||||
| @@ -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'); | ||||
|  | ||||
|  | ||||
| r.get('/', | ||||
| requirePerm(PERM.correspondence.read, { scope: 'global' }), | ||||
| // 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 base = buildScopeWhere(req.principal, { | ||||
| tableAlias: 'c', orgColumn: 'c.org_id', projectColumn: 'c.project_id', | ||||
| permCode: PERM.correspondence.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 (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); | ||||
|     const p = req.principal; | ||||
|     const params = []; | ||||
|     const cond = []; | ||||
|  | ||||
|     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); | ||||
|   } | ||||
| ); | ||||
|  | ||||
|  | ||||
| r.get('/:id', | ||||
| requirePerm(PERM.correspondence.read, { scope: 'org', getOrgId: OWN.getOrgIdById }), | ||||
| async (req, res) => { | ||||
| // 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 [[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); | ||||
| } | ||||
| ); | ||||
| }); | ||||
|  | ||||
|  | ||||
| r.post('/', | ||||
| requirePerm(PERM.correspondence.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }), | ||||
| // CREATE | ||||
| r.post( | ||||
|   "/", | ||||
|   requirePerm("corr.manage", { projectParam: "project_id" }), | ||||
|   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]); | ||||
|     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 }); | ||||
|   } | ||||
| ); | ||||
|  | ||||
|  | ||||
| r.put('/:id', | ||||
| requirePerm(PERM.correspondence.update, { scope: 'org', getOrgId: OWN.getOrgIdById }), | ||||
| async (req, res) => { | ||||
| // UPDATE | ||||
| r.put("/:id", requirePerm("corr.manage"), 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]); | ||||
|   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" }); | ||||
|  | ||||
|   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 }); | ||||
| } | ||||
| ); | ||||
| }); | ||||
|  | ||||
|  | ||||
| r.delete('/:id', | ||||
| requirePerm(PERM.correspondence.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }), | ||||
| async (req, res) => { | ||||
| // DELETE | ||||
| r.delete("/:id", requirePerm("corr.manage"), async (req, res) => { | ||||
|   const id = Number(req.params.id); | ||||
| await sql.query('DELETE FROM correspondences WHERE id=?', [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; | ||||
							
								
								
									
										56
									
								
								backend/src/routes/dashboard copy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								backend/src/routes/dashboard copy.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | ||||
| // backend/src/routes/dashboard.js | ||||
| import { Router } from "express"; | ||||
| import { Op } from "sequelize"; | ||||
| import { Correspondence, Document, RFA, User } from "../db/index.js"; // import models | ||||
| import { authJwt } from "../middleware/index.js"; | ||||
|  | ||||
| const router = Router(); | ||||
|  | ||||
| // Middleware: ตรวจสอบสิทธิ์สำหรับทุก route ในไฟล์นี้ | ||||
| router.use(authJwt.verifyToken); | ||||
|  | ||||
| // === API สำหรับ User Management Widget === | ||||
| router.get("/users/summary", async (req, res, next) => { | ||||
|   try { | ||||
|     const totalUsers = await User.count(); | ||||
|     const activeUsers = await User.count({ where: { is_active: true } }); | ||||
|     // ดึง user ที่สร้างล่าสุด 5 คน | ||||
|     const recentUsers = await User.findAll({ | ||||
|       limit: 5, | ||||
|       order: [["createdAt", "DESC"]], | ||||
|       attributes: ["id", "username", "email", "createdAt"], | ||||
|     }); | ||||
|  | ||||
|     res.json({ | ||||
|       total: totalUsers, | ||||
|       active: activeUsers, | ||||
|       inactive: totalUsers - activeUsers, | ||||
|       recent: recentUsers, | ||||
|     }); | ||||
|   } catch (error) { | ||||
|     next(error); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| // === API อื่นๆ สำหรับ Dashboard ที่เราคุยกันไว้ก่อนหน้า === | ||||
| router.get("/stats", async (req, res, next) => { | ||||
|   try { | ||||
|     const sevenDaysAgo = new Date(new Date().setDate(new Date().getDate() - 7)); | ||||
|  | ||||
|     const totalDocuments = await Document.count(); | ||||
|     const newThisWeek = await Document.count({ | ||||
|       where: { createdAt: { [Op.gte]: sevenDaysAgo } }, | ||||
|     }); | ||||
|     const pendingRfas = await RFA.count({ where: { status: "pending" } }); // สมมติตาม status | ||||
|  | ||||
|     res.json({ | ||||
|       totalDocuments, | ||||
|       newThisWeek, | ||||
|       pendingRfas, | ||||
|     }); | ||||
|   } catch (error) { | ||||
|     next(error); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| export default router; | ||||
							
								
								
									
										63
									
								
								backend/src/routes/dashboard.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										63
									
								
								backend/src/routes/dashboard.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| // backend/src/routes/dashboard.js | ||||
| import { Router } from 'express'; | ||||
| import { Op } from 'sequelize'; | ||||
|  | ||||
| // 1. Import Middleware ที่ถูกต้อง | ||||
| import { authJwt } from '../middleware/authJwt.js'; | ||||
| import { loadPrincipalMw } from '../middleware/loadPrincipal.js'; | ||||
|  | ||||
| // 2. Import Sequelize Models จาก `sequelize.js` ไม่ใช่ `index.js` | ||||
| import { Correspondence, Document, RFA, User } from '../db/sequelize.js'; | ||||
|  | ||||
| const router = Router(); | ||||
|  | ||||
| // 3. ใช้ Middleware Chain ที่ถูกต้อง 100% | ||||
| router.use(authJwt(), loadPrincipalMw()); | ||||
|  | ||||
|  | ||||
| // === API สำหรับ User Management Widget === | ||||
| router.get('/users/summary', async (req, res, next) => { | ||||
|     try { | ||||
|         // ตรวจสอบว่า Model ถูกโหลดแล้วหรือยัง (จำเป็นสำหรับโหมด lazy-load) | ||||
|         if (!User) { | ||||
|             return res.status(503).json({ message: 'Database models not available. Is ENABLE_SEQUELIZE=1 set?' }); | ||||
|         } | ||||
|         const totalUsers = await User.count(); | ||||
|         const activeUsers = await User.count({ where: { is_active: true } }); | ||||
|          | ||||
|         res.json({ | ||||
|             total: totalUsers, | ||||
|             active: activeUsers, | ||||
|             inactive: totalUsers - activeUsers, | ||||
|         }); | ||||
|     } catch (error) { | ||||
|         next(error); | ||||
|     } | ||||
| }); | ||||
|  | ||||
|  | ||||
| // === API อื่นๆ สำหรับ Dashboard ที่เราคุยกันไว้ก่อนหน้า === | ||||
| router.get('/stats', async (req, res, next) => { | ||||
|     try { | ||||
|         if (!Document || !RFA) { | ||||
|             return res.status(503).json({ message: 'Database models not available. Is ENABLE_SEQUELIZE=1 set?' }); | ||||
|         } | ||||
|  | ||||
|         const sevenDaysAgo = new Date(new Date().setDate(new Date().getDate() - 7)); | ||||
|  | ||||
|         const totalDocuments = await Document.count(); | ||||
|         const newThisWeek = await Document.count({ where: { createdAt: { [Op.gte]: sevenDaysAgo } } }); | ||||
|         const pendingRfas = await RFA.count({ where: { status: 'pending' } }); // สมมติตาม status | ||||
|  | ||||
|         res.json({ | ||||
|             totalDocuments, | ||||
|             newThisWeek, | ||||
|             pendingRfas | ||||
|         }); | ||||
|     } catch (error) { | ||||
|         next(error); | ||||
|     } | ||||
| }); | ||||
|  | ||||
|  | ||||
| export default router; | ||||
| @@ -1,58 +1,149 @@ | ||||
| import { Router } from 'express'; | ||||
| 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; | ||||
| // 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 }); | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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 }); | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -1,41 +0,0 @@ | ||||
|  Volume in drive S is Container | ||||
|  Volume Serial Number is 1F5F-1DEB | ||||
|  | ||||
|  Directory of S:\backend\src\routes | ||||
|  | ||||
| 11 Sep 25  16:34    <DIR>          . | ||||
| 11 Sep 25  16:33    <DIR>          .. | ||||
| 10 Sep 25  16:42             1,713 auth.js | ||||
| 11 Sep 25  16:20             1,097 users.js | ||||
| 11 Sep 25  16:20             2,624 correspondences.js | ||||
| 08 Sep 25  08:58             1,306 rfa.js | ||||
| 11 Sep 25  16:20             2,671 transmittals.js | ||||
| 08 Sep 25  08:58             2,448 technicaldocs.js | ||||
| 08 Sep 25  08:58             2,410 contractdwg.js | ||||
| 08 Sep 25  08:46             2,234 admin.js | ||||
| 10 Sep 25  16:42               359 health.js | ||||
| 10 Sep 25  17:35               501 auth_extras.js | ||||
| 10 Sep 25  18:18             2,980 documents.js | ||||
| 11 Sep 25  16:20             2,848 drawings.js | ||||
| 10 Sep 25  18:18             4,514 files_extras.js | ||||
| 10 Sep 25  17:35             1,222 lookups.js | ||||
| 10 Sep 25  18:18             4,285 maps.js | ||||
| 10 Sep 25  17:35               630 module_files.js | ||||
| 11 Sep 25  16:23             5,182 mvp.js | ||||
| 10 Sep 25  17:35               729 ops.js | ||||
| 11 Sep 25  16:20             1,197 organizations.js | ||||
| 11 Sep 25  16:20             2,266 projects.js | ||||
| 10 Sep 25  17:42             3,604 rbac_admin.js | ||||
| 11 Sep 25  16:20             2,826 rfas.js | ||||
| 10 Sep 25  17:53             2,376 subcategories.js | ||||
| 11 Sep 25  16:20             2,090 uploads.js | ||||
| 10 Sep 25  17:35             2,320 users_extras.js | ||||
| 10 Sep 25  18:18             4,766 views.js | ||||
| 11 Sep 25  16:20             1,199 volumes.js | ||||
| 11 Sep 25  16:20               479 permissions.js | ||||
| 11 Sep 25  16:20             2,751 contracts.js | ||||
| 11 Sep 25  16:20             2,630 contract_dwg.js | ||||
| 11 Sep 25  16:20             1,782 categories.js | ||||
| 11 Sep 25  16:34               148 list.txt | ||||
|               32 File(s)         70,187 bytes | ||||
|                2 Dir(s)  3,725,382,303,744 bytes free | ||||
| @@ -1,120 +1,125 @@ | ||||
| // src/routes/lookup.js (ESM) | ||||
| 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; | ||||
|       } else { | ||||
|         result.categories = []; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // 4) Subcategories (global master) — require category.read | ||||
|     if (pick.has('subcategory')) { | ||||
|       const can = req.principal.isSuperAdmin || req.principal.perms.has(PERM.category.read); | ||||
|       if (can) { | ||||
|         const [rows] = await sql.query('SELECT sub_cat_id, cat_id, sub_cat_name FROM subcategories ORDER BY sub_cat_name'); | ||||
|         result.subcategories = rows; | ||||
|       } else { | ||||
|         result.subcategories = []; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // 5) Volumes (global master) — require volume.read | ||||
|     if (pick.has('volume')) { | ||||
|       const can = req.principal.isSuperAdmin || req.principal.perms.has(PERM.volume.read); | ||||
|       if (can) { | ||||
|         const [rows] = await sql.query('SELECT volume_id, volume_code, volume_name FROM volumes ORDER BY volume_code'); | ||||
|         result.volumes = rows; | ||||
|       } else { | ||||
|         result.volumes = []; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // 6) Permissions (global master) — require permission.read | ||||
|     if (pick.has('permission')) { | ||||
|       const can = req.principal.isSuperAdmin || req.principal.perms.has(PERM.permission.read); | ||||
|       if (can) { | ||||
|         const [rows] = await sql.query('SELECT permission_id, permission_code, description FROM permissions ORDER BY permission_code'); | ||||
|         result.permissions = rows; | ||||
|       } else { | ||||
|         result.permissions = []; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     res.json(result); | ||||
|   } | ||||
|   // Projects — ORG scope (projects.view) | ||||
|   if (picks.has("project")) { | ||||
|     try { | ||||
|       const ok = | ||||
|         req.principal?.is_superadmin || | ||||
|         req.principal?.permissions?.has?.("projects.view"); | ||||
|       if (!ok) out.projects = []; | ||||
|       else { | ||||
|         // จำกัดตาม org scope | ||||
|         const p = req.principal; | ||||
|         let rows = []; | ||||
|         if (p.is_superadmin) { | ||||
|           [rows] = await sql.query( | ||||
|             "SELECT project_id, org_id, project_code, project_name FROM projects ORDER BY project_name" | ||||
|           ); | ||||
|         } else if (p.org_ids?.length) { | ||||
|           const inSql = p.org_ids.map(() => "?").join(","); | ||||
|           [rows] = await sql.query( | ||||
|             `SELECT project_id, org_id, project_code, project_name | ||||
|              FROM projects WHERE org_id IN (${inSql}) | ||||
|              ORDER BY project_name`, | ||||
|             p.org_ids | ||||
|           ); | ||||
|         } else { | ||||
|           rows = []; | ||||
|         } | ||||
|         out.projects = rows; | ||||
|       } | ||||
|     } catch { | ||||
|       out.projects = []; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Categories/Subcategories/Volumes — GLOBAL master (settings.manage ไม่จำเป็นสำหรับการอ่าน lookup) | ||||
|   if (picks.has("category")) { | ||||
|     try { | ||||
|       out.categories = ( | ||||
|         await sql.query( | ||||
|           "SELECT cat_id, cat_code, cat_name FROM categories ORDER BY cat_name" | ||||
|         ) | ||||
|       )[0]; | ||||
|     } catch { | ||||
|       out.categories = []; | ||||
|     } | ||||
|   } | ||||
|   if (picks.has("subcategory")) { | ||||
|     try { | ||||
|       out.subcategories = ( | ||||
|         await sql.query( | ||||
|           "SELECT sub_cat_id, cat_id, sub_cat_name FROM subcategories ORDER BY sub_cat_name" | ||||
|         ) | ||||
|       )[0]; | ||||
|     } catch { | ||||
|       out.subcategories = []; | ||||
|     } | ||||
|   } | ||||
|   if (picks.has("volume")) { | ||||
|     try { | ||||
|       out.volumes = ( | ||||
|         await sql.query( | ||||
|           "SELECT volume_id, volume_code, volume_name FROM volumes ORDER BY volume_code" | ||||
|         ) | ||||
|       )[0]; | ||||
|     } catch { | ||||
|       out.volumes = []; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Permissions — GLOBAL (settings.manage เท่านั้นที่ควรเห็นทั้งหมด) | ||||
|   if (picks.has("permission")) { | ||||
|     const ok = | ||||
|       req.principal?.is_superadmin || | ||||
|       req.principal?.permissions?.has?.("settings.manage"); | ||||
|     out.permissions = ok | ||||
|       ? ( | ||||
|           await sql.query( | ||||
|             "SELECT permission_id, perm_code AS permission_code, description FROM permissions ORDER BY perm_code" | ||||
|           ) | ||||
|         )[0] | ||||
|       : []; | ||||
|   } | ||||
|  | ||||
|   res.json(out); | ||||
| }); | ||||
|  | ||||
| export default r; | ||||
|   | ||||
| @@ -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; | ||||
| // ========= 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" }); | ||||
|     } | ||||
|  | ||||
| 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; | ||||
|     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); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| // 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); | ||||
|  | ||||
| // 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) } }); | ||||
|   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 }); | ||||
|     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/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) } }); | ||||
| // 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', 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 }); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| 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', 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 }); | ||||
| }); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| 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; | ||||
|   | ||||
| @@ -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); | ||||
|   }); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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 }); | ||||
|   } | ||||
| ); | ||||
|   | ||||
| @@ -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" }); | ||||
|   } | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -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' }); | ||||
|     res.json(row); | ||||
|   } | ||||
|   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; | ||||
|   | ||||
| @@ -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'); | ||||
|     res.json(rows); | ||||
|   } | ||||
| // 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; | ||||
|   | ||||
| @@ -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 }); | ||||
|   } | ||||
| ); | ||||
|   | ||||
							
								
								
									
										126
									
								
								backend/src/routes/rbac_admin copy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								backend/src/routes/rbac_admin copy.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,126 @@ | ||||
| // FILE: backend/src/routes/rbac_admin.js | ||||
| // RBAC admin — ใช้ settings.manage ทั้งหมด | ||||
| import { Router } from "express"; | ||||
| import sql from "../db/index.js"; | ||||
| import { requirePerm } from "../middleware/requirePerm.js"; | ||||
|  | ||||
| const r = Router(); | ||||
|  | ||||
| // ROLES | ||||
| r.get("/roles", requirePerm("settings.manage"), async (_req, res) => { | ||||
|   const [rows] = await sql.query( | ||||
|     "SELECT role_id, role_code, role_name, description FROM roles ORDER BY role_code" | ||||
|   ); | ||||
|   res.json(rows); | ||||
| }); | ||||
|  | ||||
| // PERMISSIONS | ||||
| r.get("/permissions", requirePerm("settings.manage"), async (_req, res) => { | ||||
|   const [rows] = await sql.query( | ||||
|     "SELECT permission_id, perm_code AS permission_code, scope_level, description FROM permissions ORDER BY perm_code" | ||||
|   ); | ||||
|   res.json(rows); | ||||
| }); | ||||
|  | ||||
| // role -> permissions | ||||
| r.get( | ||||
|   "/roles/:role_id/permissions", | ||||
|   requirePerm("settings.manage"), | ||||
|   async (req, res) => { | ||||
|     const role_id = Number(req.params.role_id); | ||||
|     const [rows] = await sql.query( | ||||
|       `SELECT p.permission_id, p.perm_code AS permission_code, p.description | ||||
|      FROM role_permissions rp | ||||
|      JOIN permissions p ON p.permission_id = rp.permission_id | ||||
|      WHERE rp.role_id=? ORDER BY p.perm_code`, | ||||
|       [role_id] | ||||
|     ); | ||||
|     res.json(rows); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| r.post( | ||||
|   "/roles/:role_id/permissions", | ||||
|   requirePerm("settings.manage"), | ||||
|   async (req, res) => { | ||||
|     const role_id = Number(req.params.role_id); | ||||
|     const { permission_id } = req.body || {}; | ||||
|     await sql.query( | ||||
|       "INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?,?)", | ||||
|       [role_id, Number(permission_id)] | ||||
|     ); | ||||
|     res.json({ ok: 1 }); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| r.delete( | ||||
|   "/roles/:role_id/permissions/:permission_id", | ||||
|   requirePerm("settings.manage"), | ||||
|   async (req, res) => { | ||||
|     const role_id = Number(req.params.role_id); | ||||
|     const permission_id = Number(req.params.permission_id); | ||||
|     await sql.query( | ||||
|       "DELETE FROM role_permissions WHERE role_id=? AND permission_id=?", | ||||
|       [role_id, permission_id] | ||||
|     ); | ||||
|     res.json({ ok: 1 }); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| // user -> roles (global/org/project scope columns มีในตาราง user_roles ตามสคีมา) | ||||
| r.get( | ||||
|   "/users/:user_id/roles", | ||||
|   requirePerm("settings.manage"), | ||||
|   async (req, res) => { | ||||
|     const user_id = Number(req.params.user_id); | ||||
|     const [rows] = await sql.query( | ||||
|       `SELECT ur.user_id, ur.role_id, r.role_code, r.role_name, ur.org_id, ur.project_id | ||||
|      FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id | ||||
|      WHERE ur.user_id=? ORDER BY r.role_code`, | ||||
|       [user_id] | ||||
|     ); | ||||
|     res.json(rows); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| r.post( | ||||
|   "/users/:user_id/roles", | ||||
|   requirePerm("settings.manage"), | ||||
|   async (req, res) => { | ||||
|     const user_id = Number(req.params.user_id); | ||||
|     const { role_id, org_id = null, project_id = null } = req.body || {}; | ||||
|     await sql.query( | ||||
|       "INSERT INTO user_roles (user_id, role_id, org_id, project_id) VALUES (?,?,?,?)", | ||||
|       [ | ||||
|         user_id, | ||||
|         Number(role_id), | ||||
|         org_id ? Number(org_id) : null, | ||||
|         project_id ? Number(project_id) : null, | ||||
|       ] | ||||
|     ); | ||||
|     res.json({ ok: 1 }); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| r.delete( | ||||
|   "/users/:user_id/roles", | ||||
|   requirePerm("settings.manage"), | ||||
|   async (req, res) => { | ||||
|     const user_id = Number(req.params.user_id); | ||||
|     const { role_id, org_id = null, project_id = null } = req.body || {}; | ||||
|     // สร้างเงื่อนไขแบบ dynamic สำหรับ NULL-safe compare | ||||
|     const whereOrg = org_id === null ? "ur.org_id IS NULL" : "ur.org_id = ?"; | ||||
|     const wherePrj = | ||||
|       project_id === null ? "ur.project_id IS NULL" : "ur.project_id = ?"; | ||||
|     const params = [user_id, Number(role_id)]; | ||||
|     if (org_id !== null) params.push(Number(org_id)); | ||||
|     if (project_id !== null) params.push(Number(project_id)); | ||||
|     await sql.query( | ||||
|       `DELETE FROM user_roles ur WHERE ur.user_id=? AND ur.role_id=? AND ${whereOrg} AND ${wherePrj}`, | ||||
|       params | ||||
|     ); | ||||
|     res.json({ ok: 1 }); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| export default r; | ||||
							
								
								
									
										175
									
								
								backend/src/routes/rbac_admin.js
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						
									
										175
									
								
								backend/src/routes/rbac_admin.js
									
									
									
									
									
										
										
										Normal file → Executable 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; | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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 = []; | ||||
|  | ||||
|     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)); | ||||
|     } | ||||
|  | ||||
|     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 ")}` : ""; | ||||
|  | ||||
|     const [[{ cnt }]] = await sql.query( | ||||
|       `SELECT COUNT(*) AS cnt FROM rfas r ${where}`, | ||||
|       params | ||||
|     ); | ||||
|     const [rows] = await sql.query( | ||||
|       `SELECT r.id, r.code, r.rfa_no, r.title, r.status, r.discipline, r.due_date, r.updated_at, r.project_id | ||||
|      FROM rfas r ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`, | ||||
|       [...params, limit, offset] | ||||
|     ); | ||||
|     res.json({ data: rows, total: Number(cnt || 0), page: p, pageSize: ps }); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| // 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 P = req.principal; | ||||
|   if (!P.is_superadmin && !P.inProject(row.project_id)) | ||||
|     return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||
|   res.json(row); | ||||
| }); | ||||
|  | ||||
|       // extra filters | ||||
|       const extra = buildExtraFilters({ q, status, overdue, project_id, org_id }); | ||||
|  | ||||
|       // รวม where | ||||
|       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 rfas r WHERE ${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 | ||||
|       ); | ||||
|  | ||||
|       res.json({ data: rows, total: Number(total || 0), page: p, pageSize: ps }); | ||||
|     } catch (e) { | ||||
|       res.status(500).json({ error: e.message || 'rfas/list failed' }); | ||||
|     } | ||||
|   } | ||||
| ); | ||||
|  | ||||
| /* ------------------------------- GET ONE ------------------------------ | ||||
| // ยึดรูปแบบตรวจสิทธิ์จาก rfas.js | ||||
| ------------------------------------------------------------------------*/ | ||||
| r.get('/:id', | ||||
|   requirePerm('rfa.read', { scope: 'org', getOrgId: OWN.getOrgIdById }), | ||||
| // CREATE | ||||
| r.post( | ||||
|   "/", | ||||
|   requirePerm("rfas.create", { projectParam: "project_id" }), | ||||
|   async (req, res) => { | ||||
|     try { | ||||
|       const id = Number(req.params.id); | ||||
|       const [[row]] = await sql.query('SELECT * FROM rfas WHERE id=?', [id]); | ||||
|       if (!row) return res.status(404).json({ error: 'Not found' }); | ||||
|       res.json(row); | ||||
|     } catch (e) { | ||||
|       res.status(500).json({ error: e.message || 'rfas/detail failed' }); | ||||
|     } | ||||
|   } | ||||
| ); | ||||
|  | ||||
| /* ------------------------------- CREATE ------------------------------- | ||||
| // ยึดรูปแบบฟิลด์ของ rfas.js (org_id, project_id, rfa_no, title, status) | ||||
| // เพิ่ม validation เบื้องต้น (title required) | ||||
| ------------------------------------------------------------------------*/ | ||||
| r.post('/', | ||||
|   requirePerm('rfa.create', { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }), | ||||
|   async (req, res) => { | ||||
|     try { | ||||
|     const { org_id, project_id, rfa_no, title, status } = req.body || {}; | ||||
|       if (!title?.trim()) return res.status(400).json({ error: 'title is required' }); | ||||
|  | ||||
|       const st = String(status || '').trim() || 'draft'; | ||||
|     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; | ||||
|   | ||||
| @@ -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; | ||||
| // 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 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 }); | ||||
|  | ||||
|     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; | ||||
|   | ||||
| @@ -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 (!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 (!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(",")})` | ||||
|         ); | ||||
|  | ||||
| // 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 }); | ||||
|         params.push(...P.project_ids); | ||||
|       } | ||||
|     } else if (project_id) { | ||||
|       cond.push("t.project_id=?"); | ||||
|       params.push(Number(project_id)); | ||||
|     } | ||||
| ); | ||||
|  | ||||
| export default r; | ||||
|     if (status) { | ||||
|       cond.push("t.status=?"); | ||||
|       params.push(status); | ||||
|     } | ||||
|     if (q) { | ||||
|       cond.push("(t.doc_no LIKE ? OR t.title LIKE ?)"); | ||||
|       params.push(`%${q}%`, `%${q}%`); | ||||
|     } | ||||
|  | ||||
|       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; | ||||
|   | ||||
| @@ -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 }; | ||||
| } | ||||
|  | ||||
| function buildExtraFilters({ project_id, org_id, tr_no, q }) { | ||||
|   const extra = []; | ||||
|   const params = {}; | ||||
|   if (project_id) { extra.push('t.project_id = :project_id'); params.project_id = Number(project_id); } | ||||
|   if (org_id)     { extra.push('t.org_id = :org_id');         params.org_id = Number(org_id); } | ||||
|   if (tr_no)      { extra.push('t.tr_no = :tr_no');           params.tr_no = tr_no; } | ||||
|   if (q) { | ||||
|     // ใช้ฟิลด์พื้นฐานที่ transmittals.js มีแน่นอน (tr_no, subject) | ||||
|     extra.push('(t.tr_no LIKE :q OR t.subject LIKE :q)'); | ||||
|     params.q = `%${q}%`; | ||||
|   } | ||||
|   return { where: extra.join(' AND '), params }; | ||||
| } | ||||
|  | ||||
| /* -------------------------------- LIST -------------------------------- | ||||
| GET /transmittals | ||||
| - คง RBAC/Scope เดิม (global + project/org scope ผ่าน buildScopeWhere) | ||||
| - เพิ่ม sort/page/pageSize/q ตามสไตล์ transmittals-1.js และตอบ meta | ||||
| ------------------------------------------------------------------------*/ | ||||
| r.get('/', | ||||
|   requirePerm(PERM.transmittal.read, { scope: 'global' }), | ||||
| // LIST | ||||
| r.get( | ||||
|   "/", | ||||
|   requirePerm("transmittals.manage", { projectParam: "project_id" }), | ||||
|   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 { project_id, tr_no, q, limit = 50, offset = 0 } = req.query; | ||||
|     const P = req.principal; | ||||
|     const cond = []; | ||||
|     const params = []; | ||||
|  | ||||
|       const base = buildScopeWhere(req.principal, { | ||||
|         tableAlias: 't', | ||||
|         orgColumn: 't.org_id', | ||||
|         projectColumn: 't.project_id', | ||||
|         permCode: PERM.transmittal.read, | ||||
|         preferProject: true | ||||
|     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) { | ||||
|       cond.push("(t.tr_no LIKE ? OR t.subject LIKE ?)"); | ||||
|       params.push(`%${q}%`, `%${q}%`); | ||||
|     } | ||||
|  | ||||
|     const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; | ||||
|     const [rows] = await sql.query( | ||||
|       `SELECT t.* FROM transmittals t ${where} ORDER BY t.id DESC LIMIT ? OFFSET ?`, | ||||
|       [...params, Number(limit), Number(offset)] | ||||
|     ); | ||||
|     res.json(rows); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| // 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 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 = 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 [rows] = await sql.query( | ||||
|         `SELECT t.*  | ||||
|          FROM transmittals t | ||||
|          WHERE ${where} | ||||
|          ORDER BY ${orderBy} | ||||
|          LIMIT :limit OFFSET :offset`, | ||||
|         params | ||||
|       ); | ||||
|  | ||||
|       res.json({ data: rows, total: Number(total || 0), page: p, pageSize: ps }); | ||||
|     } catch (e) { | ||||
|       res.status(500).json({ error: e.message || 'transmittals/list failed' }); | ||||
|     } | ||||
|   } | ||||
| ); | ||||
|  | ||||
| /* ------------------------------- GET ONE ------------------------------ */ | ||||
| r.get('/:id', | ||||
|   requirePerm(PERM.transmittal.read, { scope: 'org', getOrgId: OWN.getOrgIdById }), | ||||
| // CREATE | ||||
| r.post( | ||||
|   "/", | ||||
|   requirePerm("transmittals.manage", { projectParam: "project_id" }), | ||||
|   async (req, res) => { | ||||
|     try { | ||||
|       const id = Number(req.params.id); | ||||
|       const [[row]] = await sql.query('SELECT * FROM transmittals WHERE id=?', [id]); | ||||
|       if (!row) return res.status(404).json({ error: 'Not found' }); | ||||
|       res.json(row); | ||||
|     } catch (e) { | ||||
|       res.status(500).json({ error: e.message || 'transmittals/detail failed' }); | ||||
|     } | ||||
|   } | ||||
| ); | ||||
|  | ||||
| /* -------------------------------- CREATE ------------------------------ */ | ||||
| r.post('/', | ||||
|   requirePerm(PERM.transmittal.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }), | ||||
|   async (req, res) => { | ||||
|     try { | ||||
|       // ยึดสคีมาหลักจาก transmittals.js | ||||
|       const { org_id, project_id, tr_no, subject, status } = req.body; | ||||
|     const { 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; | ||||
|   | ||||
| @@ -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 }); | ||||
| }; | ||||
|  | ||||
| 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) => { | ||||
|   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); | ||||
|       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) => { | ||||
| const ts = Date.now(); | ||||
| const safe = file.originalname.replace(/[\^\w.\-]+/g, '_'); | ||||
| cb(null, `${ts}__${safe}`); | ||||
|     } 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)]); | ||||
| // 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', | ||||
| 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); | ||||
|     const perm = uploadPerm(req.params.module); | ||||
|     if (!perm) return res.status(400).json({ error: "Unsupported module" }); | ||||
|     return requirePerm(perm, { projectParam: undefined })(req, res, next); | ||||
|   }, | ||||
| upload.single('file'), | ||||
|   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 }); | ||||
|     res.json({ | ||||
|       ok: 1, | ||||
|       module, | ||||
|       ref_id: Number(id), | ||||
|       filename: file.filename, | ||||
|       path: file.path, | ||||
|       size: file.size, | ||||
|       mime: file.mimetype, | ||||
|     }); | ||||
|   } | ||||
| ); | ||||
|  | ||||
|  | ||||
| export default r; | ||||
							
								
								
									
										55
									
								
								backend/src/routes/users copy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								backend/src/routes/users copy.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| // FILE: backend/src/routes/users.js | ||||
| import { Router } from "express"; | ||||
| import sql from "../db/index.js"; | ||||
| import { requirePerm } from "../middleware/requirePerm.js"; | ||||
|  | ||||
| const r = Router(); | ||||
|  | ||||
| // ME (ทุกคน) | ||||
| r.get("/me", async (req, res) => { | ||||
|   const p = req.principal; | ||||
|   const [[u]] = await sql.query( | ||||
|     `SELECT user_id, username, email, first_name, last_name, org_id FROM users WHERE user_id=?`, | ||||
|     [p.user_id] | ||||
|   ); | ||||
|   if (!u) return res.status(404).json({ error: "User not found" }); | ||||
|   const [roles] = await sql.query( | ||||
|     `SELECT r.role_code, r.role_name, ur.org_id, ur.project_id | ||||
|        FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id | ||||
|       WHERE ur.user_id=?`, | ||||
|     [p.user_id] | ||||
|   ); | ||||
|   res.json({ | ||||
|     ...u, | ||||
|     roles, | ||||
|     role_codes: roles.map((r) => r.role_code), | ||||
|     permissions: [...(p.permissions || [])], | ||||
|     project_ids: p.project_ids, | ||||
|     org_ids: p.org_ids, | ||||
|     is_superadmin: p.is_superadmin, | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| // USERS LIST (ORG scope) — admin.access | ||||
| r.get( | ||||
|   "/", | ||||
|   requirePerm("admin.access", { orgParam: "org_id" }), | ||||
|   async (req, res) => { | ||||
|     const P = req.principal; | ||||
|     let rows = []; | ||||
|     if (P.is_superadmin) { | ||||
|       [rows] = await sql.query( | ||||
|         "SELECT user_id, username, email, org_id FROM users ORDER BY user_id DESC LIMIT 500" | ||||
|       ); | ||||
|     } else if (P.org_ids?.length) { | ||||
|       const inSql = P.org_ids.map(() => "?").join(","); | ||||
|       [rows] = await sql.query( | ||||
|         `SELECT user_id, username, email, org_id FROM users WHERE org_id IN (${inSql}) ORDER BY user_id DESC LIMIT 500`, | ||||
|         P.org_ids | ||||
|       ); | ||||
|     } | ||||
|     res.json(rows); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| export default r; | ||||
							
								
								
									
										63
									
								
								backend/src/routes/users.js
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						
									
										63
									
								
								backend/src/routes/users.js
									
									
									
									
									
										
										
										Normal file → Executable 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); | ||||
|   } | ||||
| ); | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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] | ||||
|     ); | ||||
|     res.json({ id: rs.insertId }); | ||||
|   } | ||||
|     [ | ||||
|       org_id ?? null, | ||||
|       project_id ?? null, | ||||
|       name ?? "", | ||||
|       JSON.stringify(payload_json ?? {}), | ||||
|       Number(is_shared) ? 1 : 0, | ||||
|       req.principal.user_id, | ||||
|     ] | ||||
|   ); | ||||
|   res.status(201).json({ id: rs.insertId }); | ||||
| }); | ||||
|  | ||||
| // 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; | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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(); | ||||
|  | ||||
|  | ||||
| r.get('/', | ||||
| requirePerm(PERM.volume.read, { scope: 'global' }), | ||||
| async (req, res) => { | ||||
| const [rows] = await sql.query('SELECT * FROM volumes ORDER BY volume_id DESC'); | ||||
| // 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); | ||||
| }); | ||||
|  | ||||
| // 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 }); | ||||
| }); | ||||
|  | ||||
|  | ||||
| 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) => { | ||||
| // UPDATE | ||||
| r.put("/:id", requirePerm("settings.manage"), 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 }); | ||||
| } | ||||
| ); | ||||
|   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.delete('/:id', | ||||
| requirePerm(PERM.volume.delete, { scope: 'global' }), | ||||
| async (req, res) => { | ||||
| // 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: 1 }); | ||||
| } | ||||
| ); | ||||
|  | ||||
|   await sql.query("DELETE FROM volumes WHERE volume_id=?", [id]); | ||||
|   res.json({ ok: true }); | ||||
| }); | ||||
|  | ||||
| export default r; | ||||
							
								
								
									
										17
									
								
								backend/src/utils/cookie.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								backend/src/utils/cookie.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| // FILE: backend/src/utils/cookie.js | ||||
| export const cookieOpts = (maxAgeMs) => { | ||||
|   const isProd = process.env.NODE_ENV === "production"; | ||||
|   const opts = { | ||||
|     httpOnly: true, | ||||
|     secure: true, // หลัง Nginx/HTTPS | ||||
|     sameSite: "none", // ส่งข้าม subdomain ได้ | ||||
|     path: "/", | ||||
|     maxAge: maxAgeMs, | ||||
|   }; | ||||
|   if (process.env.COOKIE_DOMAIN) opts.domain = process.env.COOKIE_DOMAIN; // เช่น .np-dms.work | ||||
|   if (!isProd && process.env.ALLOW_INSECURE_COOKIE === "1") { | ||||
|     opts.secure = false; | ||||
|     opts.sameSite = "lax"; | ||||
|   } | ||||
|   return opts; | ||||
| }; | ||||
							
								
								
									
										31
									
								
								backend/src/utils/jwt.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								backend/src/utils/jwt.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| // FILE: backend/src/utils/jwt.js | ||||
| import jwt from "jsonwebtoken"; | ||||
|  | ||||
| const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || "dev_access_secret"; | ||||
| const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || "dev_refresh_secret"; | ||||
|  | ||||
| export const ACCESS_TTL_MS = parseInt( | ||||
|   process.env.ACCESS_TTL_MS || `${15 * 60 * 1000}`, | ||||
|   10 | ||||
| ); // 15 นาที | ||||
| export const REFRESH_TTL_MS = parseInt( | ||||
|   process.env.REFRESH_TTL_MS || `${7 * 24 * 60 * 60 * 1000}`, | ||||
|   10 | ||||
| ); // 7 วัน | ||||
|  | ||||
| export function signAccessToken(payload) { | ||||
|   return jwt.sign(payload, ACCESS_SECRET, { | ||||
|     expiresIn: Math.floor(ACCESS_TTL_MS / 1000), | ||||
|   }); | ||||
| } | ||||
| export function signRefreshToken(payload) { | ||||
|   return jwt.sign(payload, REFRESH_SECRET, { | ||||
|     expiresIn: Math.floor(REFRESH_TTL_MS / 1000), | ||||
|   }); | ||||
| } | ||||
| export function verifyAccessToken(token) { | ||||
|   return jwt.verify(token, ACCESS_SECRET, { clockTolerance: 10 }); // เผื่อเวลา QNAP คลาด | ||||
| } | ||||
| export function verifyRefreshToken(token) { | ||||
|   return jwt.verify(token, REFRESH_SECRET, { clockTolerance: 10 }); | ||||
| } | ||||
| @@ -1,4 +1,14 @@ | ||||
| import bcrypt from 'bcrypt'; | ||||
| // FILE: backend/src/utils/passwords.js | ||||
| // Password hashing and verification utilities | ||||
| // - Uses bcrypt for secure password hashing | ||||
| // - Provides hashPassword(plain) and verifyPassword(plain, hash) functions | ||||
| // - hashPassword returns a promise that resolves to the hashed password | ||||
| // - verifyPassword returns a promise that resolves to true/false | ||||
| // - Uses 10 salt rounds for hashing | ||||
| // - Assumes bcrypt package is installed | ||||
| // - Suitable for user authentication systems | ||||
| // - Can be used in user registration and login flows | ||||
| import bcrypt from "bcrypt"; | ||||
| export async function hashPassword(plain) { | ||||
|   const saltRounds = 10; | ||||
|   return bcrypt.hash(plain, saltRounds); | ||||
|   | ||||
| @@ -1,40 +1,58 @@ | ||||
| // 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*/` | ||||
|   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]); | ||||
|   `, | ||||
|     [userId] | ||||
|   ); | ||||
|  | ||||
|  | ||||
| const [permRows] = await sql.query(/*sql*/` | ||||
|   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]); | ||||
|   `, | ||||
|     [userId] | ||||
|   ); | ||||
|  | ||||
|  | ||||
| const roleCodes = new Set(rolesRows.map(r => r.role_code)); | ||||
| const isSuperAdmin = roleCodes.has('SUPER_ADMIN'); | ||||
|   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 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 (!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); | ||||
|   } | ||||
| @@ -55,30 +73,35 @@ return { | ||||
|  * - ADMIN: ผ่านได้ "ภายใน org ของตัวเอง" เท่านั้น | ||||
|  * - อื่น ๆ: ต้องถือ permission_code และเข้า scope ที่ถูกต้อง | ||||
|  */ | ||||
| export function canPerform(principal, permCode, { scope = 'global', orgId = null, projectId = null } = {}) { | ||||
| 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'); | ||||
|   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 (scope === "org") { | ||||
|     if (!orgId) return false; | ||||
| if (hasAdminRole && principal.orgIds.has(orgId)) return !!principal.perms.get(permCode); | ||||
|     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 (scope === "project") { | ||||
|     if (!projectId) return false; | ||||
| if (hasAdminRole && principal.projectIds.has(projectId)) return !!principal.perms.get(permCode); | ||||
|     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 ( | ||||
|       !!entry && | ||||
|       (entry.projectIds.has(projectId) || entry.projectIds.size === 0) | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|  | ||||
|   return false; | ||||
| } | ||||
| @@ -1,3 +1,14 @@ | ||||
| // FILE: backend/src/utils/scope.js | ||||
| // 03.2 5) เพิ่ม utils/scope.js (ใหม่) | ||||
| // - ใช้ร่วมกับ requirePerm() และ loadPrincipal() | ||||
| // - สำหรับสร้าง SQL WHERE clause ในการ list entities ตาม scope ของผู้ใช้ | ||||
| // Scope and permission utilities | ||||
| // - Functions to build SQL WHERE clauses based on user principal and permissions | ||||
| // - Used for filtering list queries according to user's | ||||
| //   roles, permissions, and associated orgs/projects | ||||
| // - Works with rbac.js loadPrincipal() output | ||||
| // - Supports SUPER_ADMIN, ADMIN, and scoped permissions | ||||
|  | ||||
| /** | ||||
|  * สร้าง WHERE fragment + params สำหรับ list ตาม principal | ||||
|  * - 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; | ||||
|     }, | ||||
|   }; | ||||
|   | ||||
							
								
								
									
										15
									
								
								backend/tests/health.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								backend/tests/health.test.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| import app from "../src/index.js"; // สมมติว่าคุณ export app จาก src/index.js | ||||
| import request from "supertest"; | ||||
|  | ||||
| // ปิด server หลังจากเทสเสร็จ | ||||
| afterAll((done) => { | ||||
|   app.server.close(done); | ||||
| }); | ||||
|  | ||||
| describe("GET /health", () => { | ||||
|   it("should respond with 200 OK and a health message", async () => { | ||||
|     const response = await request(app).get("/health"); | ||||
|     expect(response.statusCode).toBe(200); | ||||
|     expect(response.text).toContain("Backend is healthy"); | ||||
|   }); | ||||
| }); | ||||
| @@ -1,11 +0,0 @@ | ||||
| { | ||||
|   "folders": [ | ||||
|     { | ||||
|       "path": "." | ||||
|     }, | ||||
|     { | ||||
|       "path": "S:/Documents" | ||||
|     } | ||||
|   ], | ||||
|   "settings": {} | ||||
| } | ||||
							
								
								
									
										2
									
								
								docker-backend-build.yml
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										2
									
								
								docker-backend-build.yml
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							| @@ -16,7 +16,7 @@ services: | ||||
|       target: prod | ||||
|     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 | ||||
|   | ||||
							
								
								
									
										45
									
								
								docker-compose.yml
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										45
									
								
								docker-compose.yml
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							| @@ -1,4 +1,4 @@ | ||||
| # DMS Container v0_6_0 | ||||
| # DMS Container v0_7_0 | ||||
| # version: "3.8" | ||||
| x-restart: &restart_policy | ||||
|   restart: unless-stopped | ||||
| @@ -57,8 +57,8 @@ 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: | ||||
| @@ -81,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" | ||||
| @@ -115,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" | ||||
|       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] | ||||
| @@ -145,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 | ||||
| @@ -257,12 +271,13 @@ services: | ||||
|       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" | ||||
| @@ -320,8 +335,8 @@ services: | ||||
|     depends_on: | ||||
|       backend: | ||||
|         condition: service_healthy | ||||
|       frontend: | ||||
|         condition: service_healthy | ||||
|       # frontend: | ||||
|       #  condition: service_healthy | ||||
|       phpmyadmin: | ||||
|         condition: service_started | ||||
|       n8n: | ||||
|   | ||||
							
								
								
									
										26
									
								
								docker-frontend-build.yml
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										26
									
								
								docker-frontend-build.yml
									
									
									
									
									
										
										
										Executable file → Normal 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 . | ||||
| @@ -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
									
								
							
							
						
						
									
										9
									
								
								frontend/.editorconfig
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| root = true | ||||
|  | ||||
| [*] | ||||
| charset = utf-8 | ||||
| end_of_line = lf | ||||
| indent_style = space | ||||
| indent_size = 2 | ||||
| insert_final_newline = true | ||||
| trim_trailing_whitespace = true | ||||
							
								
								
									
										15
									
								
								frontend/.eslintrc.json
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										15
									
								
								frontend/.eslintrc.json
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| // .eslintrc.json | ||||
| { | ||||
|   "root": true, | ||||
|   "extends": ["next/core-web-vitals"], | ||||
|   "parserOptions": { | ||||
|     "ecmaVersion": 2023, | ||||
|     "sourceType": "module" | ||||
|   }, | ||||
|   "rules": { | ||||
|     "react/no-unescaped-entities": "off", | ||||
|     "@next/next/no-img-element": "off", | ||||
|     "no-console": ["warn", { "allow": ["warn", "error"] }] | ||||
|   }, | ||||
|   "ignorePatterns": ["node_modules/", ".next/", "dist/", "coverage/"] | ||||
| } | ||||
							
								
								
									
										11
									
								
								frontend/.prettierrc.json
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										11
									
								
								frontend/.prettierrc.json
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| // File: .prettierrc.json | ||||
| { | ||||
|   "semi": true, | ||||
|   "singleQuote": false, | ||||
|   "tabWidth": 2, | ||||
|   "printWidth": 100, | ||||
|   "trailingComma": "es5", | ||||
|   "bracketSpacing": true, | ||||
|   "arrowParens": "always", | ||||
|   "endOfLine": "lf" | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user