Compare commits
	
		
			59 Commits
		
	
	
		
			64b0c6222b
			...
			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 | ||
|   | 4cb7801fe8 | ||
|   | 9a9d0955a2 | 
							
								
								
									
										0
									
								
								.github/copilot-instructions.md
									
									
									
									
										vendored
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										0
									
								
								.github/copilot-instructions.md
									
									
									
									
										vendored
									
									
										
										
										Executable file → Normal file
									
								
							
							
								
								
									
										20
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										20
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										
										
										Executable file → Normal file
									
								
							| @@ -1,7 +1,16 @@ | |||||||
| # ยกเว้นโฟลเดอร์ | # ยกเว้นโฟลเดอร์ | ||||||
| .devcontainer/ | .devcontainer/ | ||||||
|  | .qsync/ | ||||||
| @Recently-Snapshot/ | @Recently-Snapshot/ | ||||||
| Documents/ | Documents/ | ||||||
|  | mariadb/data/ | ||||||
|  | n8n*/ | ||||||
|  | n8n-postgres/ | ||||||
|  | npm/ | ||||||
|  | phpmyadmin/ | ||||||
|  | pgadmin/ | ||||||
|  | .tmp.driveupload | ||||||
|  | .qsync | ||||||
| # ===================================================== | # ===================================================== | ||||||
| # IDE/Editor settings | # IDE/Editor settings | ||||||
| # ===================================================== | # ===================================================== | ||||||
| @@ -14,9 +23,9 @@ Documents/ | |||||||
| /frontend/node_modules/ | /frontend/node_modules/ | ||||||
| **/node_modules/ | **/node_modules/ | ||||||
| # lockfiles | # lockfiles | ||||||
| /backend/package-lock.json | # /backend/package-lock.json | ||||||
| /frontend/package-lock.json | # /frontend/package-lock.json | ||||||
| **/package-lock.json | # **/package-lock.json | ||||||
| # ===================================================== | # ===================================================== | ||||||
| # Next.js build output | # Next.js build output | ||||||
| # ===================================================== | # ===================================================== | ||||||
| @@ -84,4 +93,9 @@ docker-compose.override.*.yml | |||||||
| /backend/.cache/ | /backend/.cache/ | ||||||
| /frontend/.cache/ | /frontend/.cache/ | ||||||
| .tmp/ | .tmp/ | ||||||
|  | .tmp*.*/ | ||||||
| .cache/ | .cache/ | ||||||
|  | # Ignore Nginx Proxy Manager data | ||||||
|  | /npm/ | ||||||
|  |  | ||||||
|  | /n8n-postgres/ | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| [/dms] | [/dms] | ||||||
| max_log = 361676 | max_log = 510381 | ||||||
| number = 5 | number = 3 | ||||||
| finish = 1 | finish = 1 | ||||||
|   | |||||||
							
								
								
									
										21225
									
								
								.qsync/meta/qmeta0
									
									
									
									
									
								
							
							
						
						
									
										21225
									
								
								.qsync/meta/qmeta0
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										22597
									
								
								.qsync/meta/qmeta1
									
									
									
									
									
								
							
							
						
						
									
										22597
									
								
								.qsync/meta/qmeta1
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										19358
									
								
								.qsync/meta/qmeta2
									
									
									
									
									
								
							
							
						
						
									
										19358
									
								
								.qsync/meta/qmeta2
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										10997
									
								
								.qsync/meta/qmeta3
									
									
									
									
									
								
							
							
						
						
									
										10997
									
								
								.qsync/meta/qmeta3
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										1955
									
								
								.qsync/meta/qmeta4
									
									
									
									
									
								
							
							
						
						
									
										1955
									
								
								.qsync/meta/qmeta4
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										506
									
								
								Architech.md
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										506
									
								
								Architech.md
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,506 @@ | |||||||
|  | # DMS Architecture Deep Dive (Backend + Frontend) | ||||||
|  |  | ||||||
|  | **Project:** Document Management System (DMS) — LCBP3 | ||||||
|  | **Platform:** QNAP TS‑473A (Container Station) | ||||||
|  | **Last updated:** 2025‑10‑07 (UTC+7) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 0) TL;DR (Executive Summary) | ||||||
|  |  | ||||||
|  | * Reverse proxy (Nginx/NPM) เผยแพร่ Frontend (Next.js) และ Backend (Node.js/Express) ผ่าน HTTPS (HSTS) | ||||||
|  | * Backend เชื่อม MariaDB 10.11 (ข้อมูลหลัก DMS) และแยก n8n + Postgres 16 สำหรับ workflow | ||||||
|  | * RBAC/ABAC ถูกบังคับใช้งานใน middleware + มีชุด SQL (tables → triggers → procedures → views → seed) | ||||||
|  | * ไฟล์จริง (PDF/DWG) เก็บนอก webroot ที่ **/share/dms‑data** พร้อมมาตรฐานการตั้งชื่อ+โฟลเดอร์ | ||||||
|  | * Dev/Prod แยกชัดเจนผ่าน Docker multi‑stage + docker‑compose + โฟลเดอร์ persist logs/config/certs | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 1) Runtime Topology & Trust Boundaries | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | Internet Clients (Browser) | ||||||
|  |     │   HTTPS 443 (HSTS)  [QNAP mgmt = 8443] | ||||||
|  |     ▼ | ||||||
|  | ┌─────────────────────────────────────────────────────┐ | ||||||
|  | │ Reverse Proxy Layer                                 │ | ||||||
|  | │  ├─ Nginx (Alpine)  or  Nginx Proxy Manager (NPM)   │ | ||||||
|  | │  ├─ TLS (LE cert; SAN multi‑subdomain)              │ | ||||||
|  | │  └─ Routes:                                         │ | ||||||
|  | │     • /, /_next/*   → Frontend (Next.js :3000)      │ | ||||||
|  | │     • /api/*        → Backend (Express :3001)       │ | ||||||
|  | │     • /pma/*        → phpMyAdmin                    │ | ||||||
|  | │     • /n8n/*        → n8n (Workflows)               │ | ||||||
|  | └─────────────────────────────────────────────────────┘ | ||||||
|  |                │                         │ | ||||||
|  |                │                         └──────────┐ | ||||||
|  |                ▼                                    │ | ||||||
|  |          Frontend (Next.js)                         │ | ||||||
|  |                │  Cookie-based Auth (HttpOnly)      │ | ||||||
|  |                ▼                                    ▼ | ||||||
|  |          Backend (Node/Express ESM)  ─────────►  MariaDB 10.11 | ||||||
|  |                │                                    │ | ||||||
|  |                └────────────────────────────────────┘ | ||||||
|  |                Project data (.pdf/.dwg) @ /share/dms-data | ||||||
|  |  | ||||||
|  |          n8n (workflows) ──► Postgres 16  (separate DB for automations) | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **Trust Boundaries** | ||||||
|  |  | ||||||
|  | * Public zone: Internet ↔ Reverse proxy | ||||||
|  | * App zone: Reverse proxy ↔ FE/BE containers (internal Docker network) | ||||||
|  | * Data zone: Backend ↔ Databases (MariaDB, Postgres) + `/share/dms-data` | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 2) Frontend Architecture (Next.js / React) | ||||||
|  |  | ||||||
|  | ### 2.1 Stack & Key libs | ||||||
|  |  | ||||||
|  | * **Next.js (App Router)**, **React**, ESM | ||||||
|  | * **Tailwind CSS**, **PostCSS**, **shadcn/ui** (components.json) | ||||||
|  | * Fetch API (credentials include) → Cookie Auth (HttpOnly) | ||||||
|  |  | ||||||
|  | ### 2.2 Directory Layout | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | /frontend/ | ||||||
|  | ├─ app/ | ||||||
|  | │  ├─ login/ | ||||||
|  | │  ├─ dashboard/ | ||||||
|  | │  ├─ users/ | ||||||
|  | │  ├─ correspondences/ | ||||||
|  | │  ├─ health/ | ||||||
|  | │  └─ layout.tsx / page.tsx (ตาม App Router) | ||||||
|  | ├─ public/ | ||||||
|  | ├─ Dockerfile (multi-stage: dev/prod) | ||||||
|  | ├─ package.json | ||||||
|  | ├─ next.config.js | ||||||
|  | └─ ... | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 2.3 Routing & Layouts | ||||||
|  |  | ||||||
|  | * **Public**: `/login`, `/health` | ||||||
|  | * **Protected**: `/dashboard`, `/users`, `/correspondences`, ... (client-side guard) | ||||||
|  | * เก็บ **middleware.ts (ของเดิม)** เพื่อหลีกเลี่ยง regression; ใช้ client‑guard + server action อย่างระมัดระวัง | ||||||
|  |  | ||||||
|  | ### 2.4 Auth Flow (Cookie-based) | ||||||
|  |  | ||||||
|  | 1. ผู้ใช้ submit form `/login` → `POST /api/auth/login` (Backend) | ||||||
|  | 2. Backend set **HttpOnly** cookie (JWT) + `SameSite=Lax/Strict`, `Secure` | ||||||
|  | 3. หน้า protected เรียก `GET /api/auth/me` เพื่อตรวจสอบสถานะ | ||||||
|  | 4. หาก 401 → redirect → `/login` | ||||||
|  |  | ||||||
|  | > **CORS/Fetch**: เปิด `credentials: 'include'` ทุกครั้ง, ตั้ง `NEXT_PUBLIC_API_BASE` เป็น origin ของ backend ผ่าน proxy (เช่น `https://lcbp3.np-dms.work`) | ||||||
|  |  | ||||||
|  | ### 2.5 UI/UX | ||||||
|  |  | ||||||
|  | * Sea‑blue palette, sidebar พับได้, card‑based KPI | ||||||
|  | * ตารางข้อมูลเตรียมรองรับ **server‑side DataTables** | ||||||
|  | * shadcn/ui: Button, Card, Badge, Tabs, Dropdown, Tooltip, Switch, etc. | ||||||
|  |  | ||||||
|  | ### 2.6 Config & ENV | ||||||
|  |  | ||||||
|  | * `NEXT_PUBLIC_API_BASE` (ex: `https://lcbp3.np-dms.work`) | ||||||
|  | * Build output แยก dev/prod; ระวัง EACCES บน QNAP → ใช้ user `node` + ปรับสิทธิ์โวลุ่ม `.next/*` | ||||||
|  |  | ||||||
|  | ### 2.7 Error Handling & Observability (FE) | ||||||
|  |  | ||||||
|  | * Global error boundary (app router) + toast/alert patterns | ||||||
|  | * Network layer: แยก handler สำหรับ 401/403/500 + retry/backoff ที่จำเป็น | ||||||
|  | * Metrics (optional): web‑vitals, UX timing (เก็บฝั่ง n8n หรือ simple logging) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 3) Backend Architecture (Node.js ESM / Express) | ||||||
|  |  | ||||||
|  | ### 3.1 Stack & Structure | ||||||
|  |  | ||||||
|  | * Node 20.x, **ESM** modules, **Express** | ||||||
|  | * `mysql2/promise`, `jsonwebtoken`, `cookie-parser`, `cors`, `helmet`, `winston/morgan` | ||||||
|  |  | ||||||
|  | ```tree | ||||||
|  | /backend/ | ||||||
|  | ├─ src/ | ||||||
|  | │  ├─ index.js               # bootstrap server, CORS, cookies, health | ||||||
|  | │  ├─ routes/ | ||||||
|  | │  │   ├─ auth.js            # /api/auth/* (login, me, logout) | ||||||
|  | │  │   ├─ users.js           # /api/users/* | ||||||
|  | │  │   ├─ correspondences.js # /api/correspondences/* | ||||||
|  | │  │   ├─ drawings.js        # /api/drawings/* | ||||||
|  | │  │   ├─ rfas.js            # /api/rfas/* | ||||||
|  | │  │   └─ transmittals.js    # /api/transmittals/* | ||||||
|  | │  ├─ middleware/ | ||||||
|  | │  │   ├─ authGuard.js       # verify JWT from cookie | ||||||
|  | │  │   ├─ requirePermission.js# RBAC/ABAC enforcement | ||||||
|  | │  │   ├─ errorHandler.js | ||||||
|  | │  │   └─ requestLogger.js | ||||||
|  | │  ├─ db/ | ||||||
|  | │  │   ├─ pool.js            # createPool, sane defaults | ||||||
|  | │  │   └─ models/            # query builders (User, Drawing, ...) | ||||||
|  | │  ├─ utils/ | ||||||
|  | │  │   ├─ hash.js (bcrypt/argon2) | ||||||
|  | │  │   ├─ jwt.js | ||||||
|  | │  │   ├─ pagination.js | ||||||
|  | │  │   └─ responses.js | ||||||
|  | │  └─ config/ | ||||||
|  | │      └─ index.js           # env, constants | ||||||
|  | ├─ Dockerfile | ||||||
|  | └─ package.json | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 3.2 Request Lifecycle | ||||||
|  |  | ||||||
|  | 1. `helmet` + `cors` (allow specific origin; credentials true) | ||||||
|  | 2. `cookie-parser`, `json limit` (e.g., 2MB) | ||||||
|  | 3. `requestLogger` → trace + response time | ||||||
|  | 4. Route handler → `authGuard` (protected) → `requirePermission` (per‑route) → Controller | ||||||
|  | 5. Error bubbles → `errorHandler` (JSON shape, status map) | ||||||
|  |  | ||||||
|  | ### 3.3 Auth & RBAC/ABAC | ||||||
|  |  | ||||||
|  | * **JWT** ใน HttpOnly cookie; Claims: `sub` (user_id), `roles`, `exp` | ||||||
|  | * **authGuard**: ตรวจ token → แนบ `req.user` | ||||||
|  | * **requirePermission**: เช็ค permission ตามเส้นทาง/วิธี; แผนขยาย ABAC (เช่น project scope, owner, doc state) | ||||||
|  | * Roles/Permissions ถูก seed ใน SQL; มี **view เมทริกซ์** เพื่อ debug (เช่น `v_role_permission_matrix`) | ||||||
|  |  | ||||||
|  | **ตัวอย่าง pseudo** `requirePermission(permission)` | ||||||
|  |  | ||||||
|  | ```js | ||||||
|  | export const requirePermission = (perm) => async (req, res, next) => { | ||||||
|  |   if (!req.user) return res.status(401).json({ error: 'Unauthenticated' }); | ||||||
|  |   const ok = await checkPermission(req.user.user_id, perm, req.context); | ||||||
|  |   if (!ok) return res.status(403).json({ error: 'Forbidden' }); | ||||||
|  |   return next(); | ||||||
|  | }; | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 3.4 Database Access & Pooling | ||||||
|  |  | ||||||
|  | * `createPool({ connectionLimit: 10~25, queueLimit: 0, waitForConnections: true })` | ||||||
|  | * ใช้ parameterized queries เสมอ; ปรับ `sql_mode` ที่จำเป็นใน `my.cnf` | ||||||
|  |  | ||||||
|  | ### 3.5 File Storage & Secure Download | ||||||
|  |  | ||||||
|  | * Root: **/share/dms‑data** | ||||||
|  | * โครงโฟลเดอร์: `{module}/{yyyy}/{mm}/{entityId}/` + ชื่อไฟล์ตามมาตรฐาน (เช่น `DRW-<code>-REV-<rev>.pdf`) | ||||||
|  | * Endpoint download: ตรวจสิทธิ์ (RBAC/ABAC) → `res.sendFile()`/stream; ป้องกัน path traversal | ||||||
|  | * MIME allowlist + size limit + virus scan (optional; ภายหลัง) | ||||||
|  |  | ||||||
|  | ### 3.6 Health & Readiness | ||||||
|  |  | ||||||
|  | * `GET /api/health` → `{ ok: true }` | ||||||
|  | * (optional) `/api/ready` ตรวจ DB ping + disk space (dms‑data) | ||||||
|  |  | ||||||
|  | ### 3.7 Config & ENV (BE) | ||||||
|  |  | ||||||
|  | * `DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME` | ||||||
|  | * `JWT_SECRET, COOKIE_NAME, COOKIE_SAMESITE, COOKIE_SECURE` | ||||||
|  | * `CORS_ORIGIN, LOG_LEVEL, APP_BASE_URL` | ||||||
|  | * `FILE_ROOT=/share/dms-data` | ||||||
|  |  | ||||||
|  | ### 3.8 Logging | ||||||
|  |  | ||||||
|  | * Access log (morgan) + App log (winston) → `/share/Container/dms/logs/backend/` | ||||||
|  | * รูปแบบ JSON (timestamp, level, msg, reqId) + daily rotation (logrotate/container‑side) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 4) Database (MariaDB 10.11) | ||||||
|  |  | ||||||
|  | ### 4.1 Schema Overview (ย่อ) | ||||||
|  |  | ||||||
|  | * **RBAC core**: `users`, `roles`, `permissions`, `user_roles`, `role_permissions` | ||||||
|  | * **Domain**: `drawings`, `contracts`, `correspondences`, `rfas`, `transmittals`, `organizations`, `projects`, ... | ||||||
|  | * **Audit**: `audit_logs` (แผนขยาย), `deleted_at` (soft delete, แผนงาน) | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | [users]──<user_roles>──[roles]──<role_permissions>──[permissions] | ||||||
|  |    │ | ||||||
|  |    └── activities/audit_logs (future expansion) | ||||||
|  |  | ||||||
|  | [drawings]──<mapping>──[contracts] | ||||||
|  | [rfas]──<links>──[drawings] | ||||||
|  | [correspondences] (internal/external flag) | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 4.2 Init SQL Pipeline | ||||||
|  |  | ||||||
|  | 1. `01_*_deploy_table_rbac.sql` — สร้างตารางหลักทั้งหมด + RBAC | ||||||
|  | 2. `02_*_triggers.sql` — บังคับ data rules, auto‑audit fields | ||||||
|  | 3. `03_*_procedures_handlers.sql` — upsert/bulk handlers (เช่น `sp_bulk_import_contract_dwg`) | ||||||
|  | 4. `04_*_views.sql` — รายงาน/เมทริกซ์สิทธิ์ (`v_role_permission_matrix`, etc.) | ||||||
|  | 5. `05_*_seed_data.sql` — ค่าพื้นฐาน domain (project, categories, statuses) | ||||||
|  | 6. `06_*_seed_users.sql` — บัญชีเริ่มต้น (superadmin, editors, viewers) | ||||||
|  | 7. `07_*_seed_contract_dwg.sql` — ข้อมูลตัวอย่างแบบสัญญา | ||||||
|  |  | ||||||
|  | ### 4.3 Indexing & Performance | ||||||
|  |  | ||||||
|  | * Composite indexes ตามคอลัมน์ filter/sort (เช่น `(project_id, updated_at DESC)`) | ||||||
|  | * Full‑text index (optional) สำหรับ advanced search | ||||||
|  | * Query plan review (EXPLAIN) + เพิ่ม covering index ตามรายงาน | ||||||
|  |  | ||||||
|  | ### 4.4 MySQL/MariaDB Config (my.cnf — แนวทาง) | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | [mysqld] | ||||||
|  | innodb_buffer_pool_size = 4G     # ปรับตาม RAM/QNAP | ||||||
|  | innodb_log_file_size    = 512M | ||||||
|  | innodb_flush_log_at_trx_commit = 1 | ||||||
|  | max_connections         = 200 | ||||||
|  | sql_mode                = STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION | ||||||
|  | character-set-server    = utf8mb4 | ||||||
|  | collation-server        = utf8mb4_unicode_ci | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | > ปรับค่าให้เหมาะกับ workload จริง + เฝ้าดู IO/CPU ของ QNAP | ||||||
|  |  | ||||||
|  | ### 4.5 Backup/Restore | ||||||
|  |  | ||||||
|  | * Logical backup: `mysqldump --routines --triggers --single-transaction` | ||||||
|  | * Physical (snapshot QNAP) + schedule ผ่าน n8n/cron | ||||||
|  | * เก็บสำเนา off‑NAS (encrypted) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 5) Reverse Proxy & TLS | ||||||
|  |  | ||||||
|  | ### 5.1 Nginx (Alpine) — ตัวอย่าง server block | ||||||
|  |  | ||||||
|  | > **สำคัญ:** บนสภาพแวดล้อมนี้ ให้ใช้คนละบรรทัด: | ||||||
|  | > `listen 443 ssl;` | ||||||
|  | > `http2 on;` | ||||||
|  | > หลีกเลี่ยง `listen 443 ssl http2;` | ||||||
|  |  | ||||||
|  | ```nginx | ||||||
|  | server { | ||||||
|  |   listen 80; | ||||||
|  |   server_name lcbp3.np-dms.work; | ||||||
|  |   return 301 https://$host$request_uri; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | server { | ||||||
|  |   listen 443 ssl; | ||||||
|  |   http2 on; | ||||||
|  |   server_name lcbp3.np-dms.work; | ||||||
|  |  | ||||||
|  |   ssl_certificate     /etc/nginx/certs/fullchain.pem; | ||||||
|  |   ssl_certificate_key /etc/nginx/certs/privkey.pem; | ||||||
|  |   add_header Strict-Transport-Security "max-age=63072000; preload" always; | ||||||
|  |  | ||||||
|  |   # Frontend | ||||||
|  |   location / { | ||||||
|  |     proxy_pass http://frontend:3000; | ||||||
|  |     proxy_set_header Host $host; | ||||||
|  |     proxy_set_header X-Real-IP $remote_addr; | ||||||
|  |     proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | ||||||
|  |     proxy_set_header X-Forwarded-Proto $scheme; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   # Next.js static | ||||||
|  |   location /_next/ { | ||||||
|  |     proxy_pass http://frontend:3000; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   # Backend API | ||||||
|  |   location /api/ { | ||||||
|  |     proxy_http_version 1.1; | ||||||
|  |     proxy_set_header Connection ""; | ||||||
|  |     proxy_pass http://backend:3001; | ||||||
|  |     proxy_set_header Host $host; | ||||||
|  |     proxy_set_header X-Real-IP $remote_addr; | ||||||
|  |     proxy_set_header X-Forwarded-Proto $scheme; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   # phpMyAdmin (sub-path) | ||||||
|  |   location /pma/ { | ||||||
|  |     proxy_pass http://phpmyadmin:80/; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   # n8n | ||||||
|  |   location /n8n/ { | ||||||
|  |     proxy_pass http://n8n:5678/; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 5.2 Nginx Proxy Manager (NPM) — Tips | ||||||
|  |  | ||||||
|  | * ระวังอย่าใส่ `proxy_http_version` ซ้ำซ้อน (duplicate directive) ใน Advanced | ||||||
|  | * ถ้าต้องแก้ไฟล์ด้านใน NPM → ระวังไฟล์ใน `/data/nginx/proxy_host/*.conf` | ||||||
|  | * จัดการ certificate / SAN หลาย sub‑domain ใน UI แต่ mainten ดีเรื่อง symlink/renew | ||||||
|  |  | ||||||
|  | ### 5.3 TLS & Certificates | ||||||
|  |  | ||||||
|  | * Let’s Encrypt (HTTP‑01 webroot/standalone) + HSTS | ||||||
|  | * QNAP mgmt เปลี่ยนเป็น 8443 → พอร์ต 443 public ว่างสำหรับ Nginx/NPM | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 6) Docker Compose Topology | ||||||
|  |  | ||||||
|  | ### 6.1 Services (สรุป) | ||||||
|  |  | ||||||
|  | * `frontend` (Next.js) :3000 | ||||||
|  | * `backend` (Express) :3001 | ||||||
|  | * `mariadb` (10.11)    :3306 (internal) | ||||||
|  | * `phpmyadmin`         :80 (internal) | ||||||
|  | * `nginx` or `npm`     :80/443 (published) | ||||||
|  | * `n8n`                :5678 (internal) | ||||||
|  | * `postgres_n8n` (16-alpine) | ||||||
|  | * `pgadmin4` | ||||||
|  |  | ||||||
|  | ### 6.2 Volumes & Paths | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | /share/Container/dms/ | ||||||
|  | ├─ mariadb/data | ||||||
|  | ├─ mariadb/init/*.sql | ||||||
|  | ├─ backend/ (code) | ||||||
|  | ├─ frontend/ (code) | ||||||
|  | ├─ phpmyadmin/{sessions,tmp,config.user.inc.php} | ||||||
|  | ├─ nginx/{nginx.conf,dms.conf,certs/} | ||||||
|  | ├─ n8n, n8n-postgres, n8n-cache | ||||||
|  | └─ logs/{backend,frontend,nginx,pgadmin,phpmyadmin,postgres_n8n} | ||||||
|  | /share/dms-data  (pdf/dwg storage) | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 6.3 Healthchecks (suggested) | ||||||
|  |  | ||||||
|  | * **backend**: curl `http://localhost:3001/api/health` | ||||||
|  | * **frontend**: curl `/health` (simple JSON) | ||||||
|  | * **mariadb**: `mysqladmin ping` with credentials | ||||||
|  | * **nginx**: `nginx -t` at startup | ||||||
|  |  | ||||||
|  | ### 6.4 Security Hardening | ||||||
|  |  | ||||||
|  | * รัน container ด้วย user non‑root (`user: node` สำหรับ FE/BE) | ||||||
|  | * จำกัด capabilities; read‑only FS (ยกเว้นโวลุ่มจำเป็น) | ||||||
|  | * เฉพาะ backend เมานต์ `/share/dms-data` | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 7) Observability, Ops, and Troubleshooting | ||||||
|  |  | ||||||
|  | ### 7.1 Logs | ||||||
|  |  | ||||||
|  | * Frontend → `/logs/frontend/*` | ||||||
|  | * Backend  → `/logs/backend/*` (app/access/error) | ||||||
|  | * Nginx/NPM → `/logs/nginx/*` | ||||||
|  | * MariaDB   → default datadir log + slow query (เปิดใน my.cnf หากต้องการ) | ||||||
|  |  | ||||||
|  | ### 7.2 Common Issues & Playbooks | ||||||
|  |  | ||||||
|  | * **401 Unauthenticated**: ตรวจ `authGuard` → JWT cookie มี/หมดอายุ → เวลา server/FE sync → CORS `credentials: true` | ||||||
|  | * **EACCES Next.js**: สิทธิ์ `.next/*` + run as `node`, โวลุ่ม map ถูก user:group | ||||||
|  | * **NPM duplicate directive**: ลบซ้ำ `proxy_http_version` ใน Advanced / ตรวจ `proxy_host/*.conf` | ||||||
|  | * **LE cert path/symlink**: ตรวจ `/etc/letsencrypt/live/npm-*` symlink ชี้ถูก | ||||||
|  | * **DB field not found**: ตรวจ schema vs code (migration/init SQL) → sync ให้ตรง | ||||||
|  |  | ||||||
|  | ### 7.3 Performance Guides | ||||||
|  |  | ||||||
|  | * **Backend**: keep‑alive, gzip/deflate at proxy, pool 10–25, paginate, avoid N+1 | ||||||
|  | * **Frontend**: prefetch critical routes, cache static, image optimization | ||||||
|  | * **DB**: เพิ่ม index จุด filter, analyze query (EXPLAIN), ปรับ buffer pool | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 8) Security & Compliance | ||||||
|  |  | ||||||
|  | * **HTTPS only** + HSTS (preload) | ||||||
|  | * **CORS**: allow list เฉพาะ FE origin; `Access-Control-Allow-Credentials: true` | ||||||
|  | * **Cookie**: HttpOnly, Secure, SameSite=Lax/Strict | ||||||
|  | * **Input Validation**: celebrate/zod (optional) + sanitize | ||||||
|  | * **Rate limiting**: per IP/route (optional) | ||||||
|  | * **AuditLog**: วางแผนเพิ่ม ครอบคลุม CRUD + mapping (actor, action, entity, before/after) | ||||||
|  | * **Backups**: DB + `/share/dms-data` + config (encrypted off‑NAS) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 9) Backlog → Architecture Mapping | ||||||
|  |  | ||||||
|  | 1. **RBAC Enforcement ครบ** → เติม `requirePermission` ทุก route + test matrix ผ่าน view | ||||||
|  | 2. **AuditLog ครบ CRUD/Mapping** → trigger + table `audit_logs` + BE hook | ||||||
|  | 3. **Upload/Download จริงของ Drawing Revisions** → BE endpoints + virus scan (optional) | ||||||
|  | 4. **Dashboard KPI** → BE summary endpoints + FE cards/charts | ||||||
|  | 5. **Server‑side DataTables** → paging/sort/filter + indexesรองรับ | ||||||
|  | 6. **รายงาน Export CSV/Excel/PDF** → BE export endpoints + FE buttons | ||||||
|  | 7. **Soft delete** (`deleted_at`) → BE filter default scope + restore endpoint | ||||||
|  | 8. **Validation เข้ม** → celebrate/zod schema + consistent error shape | ||||||
|  | 9. **Indexing/Perf** → slow query log + EXPLAIN review | ||||||
|  | 10. **Job/Cron Deadline Alerts** → n8n schedule + SMTP | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 10) Port & ENV Matrix (Quick Ref) | ||||||
|  |  | ||||||
|  | | Component | Ports           | Key ENV                                          | | ||||||
|  | | --------- | --------------- | ------------------------------------------------ | | ||||||
|  | | Nginx/NPM | 80/443 (public) | SSL paths, HSTS                                  | | ||||||
|  | | Frontend  | 3000 (internal) | `NEXT_PUBLIC_API_BASE`                           | | ||||||
|  | | Backend   | 3001 (internal) | `DB_*`, `JWT_SECRET`, `CORS_ORIGIN`, `FILE_ROOT` | | ||||||
|  | | MariaDB   | 3306 (internal) | `MY_CNF`, credentials                            | | ||||||
|  | | n8n       | 5678 (internal) | `N8N_*`, webhook URL under `/n8n/`               | | ||||||
|  | | Postgres  | 5432 (internal) | n8n DB                                           | | ||||||
|  |  | ||||||
|  | **QNAP mgmt**: 8443 (already moved) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 11) Sample Snippets | ||||||
|  |  | ||||||
|  | ### 11.1 Backend CORS (credentials) | ||||||
|  |  | ||||||
|  | ```js | ||||||
|  | app.use(cors({ | ||||||
|  |   origin: ['https://lcbp3.np-dms.work'], | ||||||
|  |   credentials: true, | ||||||
|  | })); | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 11.2 Secure Download (guarded) | ||||||
|  |  | ||||||
|  | ```js | ||||||
|  | router.get('/files/:module/:id/:filename', authGuard, requirePermission('file.read'), async (req, res) => { | ||||||
|  |   const { module, id, filename } = req.params; | ||||||
|  |   // 1) ABAC: verify user can access this module/entity | ||||||
|  |   const ok = await canReadFile(req.user.user_id, module, id); | ||||||
|  |   if (!ok) return res.status(403).json({ error: 'Forbidden' }); | ||||||
|  |  | ||||||
|  |   const abs = path.join(FILE_ROOT, module, id, filename); | ||||||
|  |   if (!abs.startsWith(FILE_ROOT)) return res.status(400).json({ error: 'Bad path' }); | ||||||
|  |   return res.sendFile(abs); | ||||||
|  | }); | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 11.3 Healthcheck | ||||||
|  |  | ||||||
|  | ```js | ||||||
|  | router.get('/health', (req, res) => res.json({ ok: true })); | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 12) Deployment Workflow (Suggested) | ||||||
|  |  | ||||||
|  | 1. Git (Gitea) branch strategy `feature/*` → PR → main | ||||||
|  | 2. Build images (dev/prod) via Dockerfile multi‑stage; pin Node/MariaDB versions | ||||||
|  | 3. `docker compose up -d --build` จาก `/share/Container/dms` | ||||||
|  | 4. Validate: `/health`, `/api/health`, login roundtrip | ||||||
|  | 5. Monitor logs + baseline perf; run SQL smoke tests (views/triggers/procs) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 13) Appendix | ||||||
|  |  | ||||||
|  | * **Naming conventions**: snake_case DB, camelCase JS | ||||||
|  | * **Timezones**: store UTC in DB; display in app TZ (+07:00) | ||||||
|  | * **Character set**: UTF‑8 (`utf8mb4_unicode_ci`) | ||||||
|  | * **Large file policy**: size limit (e.g., 50–200MB), allowlist extensions | ||||||
|  | * **Retention**: archive strategy for old revisions (optional) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | > หากต้องการ เวอร์ชัน **README.md พร้อมโค้ดตัวอย่าง compose/nginx** จัดรูปแบบให้นำไปวางใน repo ได้ทันที แจ้งได้เลยว่าจะให้แตกไฟล์เป็น `/docs/Architecture.md` + `/nginx/dms.conf` + `/docker-compose.yml` template หรือรูปแบบอื่นที่สะดวกต่อการใช้งานของทีม | ||||||
							
								
								
									
										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) | 1. การพัฒนาเว็บแอป (Web Application Development) | ||||||
| 2. Configuration of Container Station on QNAP | 2. Configuration of Container Station on QNAP | ||||||
| 3. Database: mariadb:10.11 | 3. Database: mariadb:10.11 | ||||||
| @@ -16,14 +677,19 @@ | |||||||
| 15. ภาษา SQL | 15. ภาษา SQL | ||||||
| 16. RBAC | 16. RBAC | ||||||
|  |  | ||||||
| # ระบบที่ใช้ | ## 2. ระบบที่ใช้ | ||||||
|  |  | ||||||
| ## Server | ## 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 | - ด้วย Visual Studio Code บน Windows 11 | ||||||
| - ใช้ ๊ UI ของ Container Station เป็นหลัก | - ใช้ ๊ UI ของ Container Station เป็นหลัก | ||||||
|  |  | ||||||
| ## โครงสร้างโฟลเดอร์ (บน QNAP) | ## โครงสร้างโฟลเดอร์ (บน QNAP) | ||||||
|  |  | ||||||
| /share/Container/dms/ | /share/Container/dms/ | ||||||
| ├─ docker-compose.yml # Create โดย UI Container Station | ├─ docker-compose.yml # Create โดย UI Container Station | ||||||
| ├─ mariadb/ | ├─ mariadb/ | ||||||
| @@ -83,23 +749,29 @@ | |||||||
| └─ postgres_n8n/ | └─ postgres_n8n/ | ||||||
| /share/dms-data # เก็บข้อมมูล .pdf, .dwg แยกตาม correspondences, documents | /share/dms-data # เก็บข้อมมูล .pdf, .dwg แยกตาม correspondences, documents | ||||||
|  |  | ||||||
|  |  | ||||||
| # ภาษา: ใช้ภาษาไทยในการโต้ตอบ ยกเว้น ศัพท์เทคนิคหรือศัพท์เฉพาะทาง |  | ||||||
|  |  | ||||||
| # ไฟล์ที่ ีupload |  | ||||||
|   - Dockerfile ของ backend |  | ||||||
|   - package.json ของ backend |  | ||||||
|   - docker-compose.yml ชอง Container station |  | ||||||
|   - nginx.conf, dms.conf ของ nginx |  | ||||||
|   - dms_v0_5_0_data_v5_1_sql.zip ประกอบด้วย  |  | ||||||
|     - 01_dms_data_v5_1_deploy_table_rbac.sql # Create all data table & RBAC table here! |  | ||||||
|     - 02_dms_data_v5_1_triggers.sql # Create all triggers here! |  | ||||||
|     - 03_dms_data_v5_1_procedures_handlers.sql # Create all procedures here! |  | ||||||
|     - 04_dms_data_v5_1_views.sql # Create all views here! |  | ||||||
|     - 05 dms_data_v5_1_seeก_data.sql # Seed nescesary data here! |  | ||||||
|     - 06_dms_data_v5_1_seed_users.sql # Seed users data here! |  | ||||||
|  |  | ||||||
| # งานที่ต้องการ: | # งานที่ต้องการ: | ||||||
|  |  | ||||||
| - ไม่ใช้ .env เด็ดขาด Container Station ไม่รองรับ และ docker-compose.yml ได้ทดสอบ รันบน Container station มาแล้ว | - ไม่ใช้ .env เด็ดขาด Container Station ไม่รองรับ และ docker-compose.yml ได้ทดสอบ รันบน Container station มาแล้ว | ||||||
| - Code ของ backend ทั้งหมด | - Code ของ backend ทั้งหมด | ||||||
| - การทดสอบระบบ backend ทุกส่วน ให้พร้อม สำหรับ frontend | - การทดสอบระบบ backend ทุกส่วน ให้พร้อม สำหรับ frontend | ||||||
|  |  | ||||||
|  | # กรณี 2: มี Git อยู่แล้ว (มี main อยู่) | ||||||
|  |  | ||||||
|  | 2.1 อัปเดต main ให้ตรงล่าสุดก่อนแตกบร้านช์ | ||||||
|  |  | ||||||
|  | cd /share/Container/dms | ||||||
|  | git checkout main | ||||||
|  | git pull --ff-only # ถ้าเชื่อม remote อยู่ | ||||||
|  | git tag -f stable-$(date +%F) # tag จุดเสถียรปัจจุบัน | ||||||
|  |  | ||||||
|  | 2.2 แตก branch งาน Dashboard | ||||||
|  | git checkout -b feature/dashboard-update-$(date +%y%m%d) | ||||||
|  | git checkout -b feature/dashboard-update-251004 | ||||||
|  |  | ||||||
|  | 2.3 ทำงาน/คอมมิตตามปกติ | ||||||
|  |  | ||||||
|  | # แก้ไฟล์ frontend/app/dashboard/\* และที่เกี่ยวข้อง | ||||||
|  |  | ||||||
|  | git add frontend/app/dashboard | ||||||
|  | git commit -m "feat(dashboard): เพิ่มส่วนจัดการ user" | ||||||
|  | git push -u origin feature/dashboard-update-251004 | ||||||
|   | |||||||
							
								
								
									
										96
									
								
								b.env
									
									
									
									
									
								
							
							
						
						
									
										96
									
								
								b.env
									
									
									
									
									
								
							| @@ -1,96 +0,0 @@ | |||||||
| TZ=Asia/Bangkok |  | ||||||
| GENERIC_TIMEZONE=Asia/Bangkok |  | ||||||
| PUBLIC_DOMAIN=np-dms.work |  | ||||||
|  |  | ||||||
| PUBLIC_FRONTEND_URL=https://lcbp3.np-dms.work |  | ||||||
| PUBLIC_BACKEND_URL=https://lcbp3.np-dms.work/api |  | ||||||
| PUBLIC_N8N_URL=https://lcbp3.np-dms.work/n8n |  | ||||||
|  |  | ||||||
| MARIADB_HOST=mariadb |  | ||||||
| MARIADB_PORT=3306 |  | ||||||
| MARIADB_ROOT_PASSWORD=Center#2025 |  | ||||||
| MARIADB_DATABASE=dms |  | ||||||
| MARIADB_USER=center |  | ||||||
| MARIADB_PASSWORD=Center#2025 |  | ||||||
|  |  | ||||||
| # MARIADB_HOST_PORT=7307 |  | ||||||
| # BACKEND_HOST_PORT=7001 |  | ||||||
| # FRONTEND_HOST_PORT=7000 |  | ||||||
| # PHPMYADMIN_HOST_PORT=7070 |  | ||||||
| NGINX_HTTP_HOST_PORT=80 |  | ||||||
| NGINX_HTTPS_HOST_PORT=443 |  | ||||||
| N# 8N_HOST_PORT=7081 |  | ||||||
|  |  | ||||||
| NODE_ENV=production |  | ||||||
| JWT_SECRET=8b0df02e4aee9f9f79a4f2d8ba77b0b82c1ee3446b68cb0bae94ab54d60f8d9e |  | ||||||
| JWT_EXPIRES_IN=12h |  | ||||||
| PASSWORD_SALT_ROUNDS=10 |  | ||||||
| RATE_LIMIT_WINDOW_MS=900000 |  | ||||||
| RATE_LIMIT_MAX=200 |  | ||||||
| CORS_ORIGINS=https://lcbp3.np-dms.work,http://localhost:7000,http://192.168.20.248:7000 |  | ||||||
|  |  | ||||||
|  |  | ||||||
| NEXT_TELEMETRY_DISABLED=1 |  | ||||||
|  |  | ||||||
| PMA_HOST=mariadb |  | ||||||
| PMA_PORT=3306 |  | ||||||
| PMA_ABSOLUTE_URI=https://lcbp3.np-dms.work.com/pma/ |  | ||||||
|  |  | ||||||
| UPLOAD_LIMIT=256M |  | ||||||
| MEMORY_LIMIT=512M |  | ||||||
|  |  | ||||||
| NGINX_SERVER_NAME=np-dms.work.com |  | ||||||
| NGINX_PROXY_READ_TIMEOUT=300 |  | ||||||
|  |  | ||||||
| # QNAP_SSL_CERT_HOST=/etc/qnap-ssl/combine |  | ||||||
| # QNAP_SSL_KEY_HOST=/etc/qnap-ssl/key |  | ||||||
| # NGINX_SSL_CERT=/etc/nginx/certs/fullchain.pem |  | ||||||
| # NGINX_SSL_KEY=/etc/nginx/certs/privkey.pem |  | ||||||
| # NGINX_SSL_KEY=/etc/nginx/certs |  | ||||||
| QNAP_SSL_CERT=/etc/config/QcloudSSLCertificate/cert |  | ||||||
| NGINX_SSL_CERT=/etc/qnap-ssl |  | ||||||
|  |  | ||||||
| N8N_BASIC_AUTH_ACTIVE=true |  | ||||||
| N8N_BASIC_AUTH_USER=n8n |  | ||||||
| N8N_BASIC_AUTH_PASSWORD=Center#2025 |  | ||||||
| N8N_PATH=/n8n/ |  | ||||||
| N8N_PROTOCOL=https |  | ||||||
| N8N_PROXY_HOPS=1 |  | ||||||
| N8N_SECURE_COOKIE=true |  | ||||||
| N8N_HOST=dcs.mycloudnas.com |  | ||||||
| N8N_PORT=5678 |  | ||||||
| N8N_EDITOR_BASE_URL=https://lcbp3.np-dms.work/n8n/ |  | ||||||
| WEBHOOK_URL=https://lcbp3.np-dms.work/n8n/ |  | ||||||
| N8N_ENCRYPTION_KEY=9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI |  | ||||||
| # --- n8n → MariaDB --- |  | ||||||
| # DB_TYPE=mysqldb |  | ||||||
| # DB_MYSQLDB_HOST=mariadb |  | ||||||
| # DB_MYSQLDB_PORT=3306 |  | ||||||
| # DB_MYSQLDB_DATABASE=n8n |  | ||||||
| # DB_MYSQLDB_USER=n8n_user |  | ||||||
| # DB_MYSQLDB_PASSWORD=Center#2025   # เปลี่ยนเป็นรหัสแข็งแรงของคุณ |  | ||||||
|  |  | ||||||
| # ==== n8n → PostgreSQL (แทน MariaDB/MySQL) ==== |  | ||||||
| DB_TYPE=postgresdb |  | ||||||
| DB_POSTGRESDB_HOST=postgres_n8n |  | ||||||
| DB_POSTGRESDB_PORT=5432 |  | ||||||
| DB_POSTGRESDB_DATABASE=n8n |  | ||||||
| DB_POSTGRESDB_USER=n8n |  | ||||||
| DB_POSTGRESDB_PASSWORD=Center#2025 |  | ||||||
| # path โฟลเดอร์ n8n เดิม (มี database.sqlite) |  | ||||||
| # HOST_N8N=/share/Container/dms/n8n |  | ||||||
|  |  | ||||||
|  |  | ||||||
| HOST_BASE=/share/Container/dms |  | ||||||
| HOST_MARIADB=${HOST_BASE}/mariadb |  | ||||||
| HOST_BACKEND=${HOST_BASE}/backend |  | ||||||
| HOST_FRONTEND=${HOST_BASE}/frontend |  | ||||||
| HOST_PHPMYADMIN=${HOST_BASE}/phpmyadmin |  | ||||||
| HOST_NGINX=${HOST_BASE}/nginx |  | ||||||
| HOST_LOGS=${HOST_BASE}/logs |  | ||||||
| HOST_SCRIPTS=${HOST_BASE}/scripts |  | ||||||
| HOST_N8N=/share/Container/dms/n8n |  | ||||||
| HOST_N8N_CACHE=${HOST_BASE}/n8n-cache |  | ||||||
| HOST_DATA=/share/dms-data |  | ||||||
| # BACKEND_LOG_DIR=${HOST_LOGS}/backend |  | ||||||
| BACKEND_LOG_DIR=/app/logs |  | ||||||
							
								
								
									
										29
									
								
								backend/.backup/backend build.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								backend/.backup/backend build.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | |||||||
|  | # Backend build | ||||||
|  |  | ||||||
|  | ## วิธีสร้าง package-lock.json ด้วย Docker | ||||||
|  |  | ||||||
|  | ### 1. เช็ค uid:gid ของโฟลเดอร์โปรเจกต์บน QNAP | ||||||
|  |  | ||||||
|  | stat -c "%u:%g" . | ||||||
|  |  | ||||||
|  | ### 2. ใช้ค่าที่ได้มาแทน UID:GID | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | docker run --rm \ | ||||||
|  |   -v "/share/Container/dms/frontend:/app" -w /app \ | ||||||
|  |   --user UID:GID -e HOME=/tmp \ | ||||||
|  |   node:20-alpine sh -lc 'mkdir -p /tmp && npm install --package-lock-only --ignore-scripts' | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | สร้าง package-lock.json โดย ไม่ติดตั้ง node_modules | ||||||
|  |  | ||||||
|  | --user $(id -u):$(id -g) ทำให้ไฟล์ที่ได้เป็นเจ้าของโดยยูสเซอร์ปัจจุบัน (กันปัญหา root-owned) | ||||||
|  |  | ||||||
|  | ## ขั้นตอน Build บน QNAP | ||||||
|  |  | ||||||
|  | docker compose -f docker-backend-build.yml build --no-cache 2>&1 | tee backend_build.log | ||||||
|  |  | ||||||
|  | ## สำหรับ build local | ||||||
|  |  | ||||||
|  | cd backend | ||||||
|  | docker build -t dms-backend:dev --target dev . | ||||||
							
								
								
									
										
											BIN
										
									
								
								backend/.backup/backend_tree.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								backend/.backup/backend_tree.txt
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										0
									
								
								backend/ed25519 → backend/.backup/ed25519
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										0
									
								
								backend/ed25519 → backend/.backup/ed25519
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
								
								
									
										0
									
								
								backend/ed25519.pub → backend/.backup/ed25519.pub
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										0
									
								
								backend/ed25519.pub → backend/.backup/ed25519.pub
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
								
								
									
										64
									
								
								backend/.backup/fix-bearer-index.patch.diff
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								backend/.backup/fix-bearer-index.patch.diff
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | |||||||
|  | diff --git a/src/index.js b/src/index.js | ||||||
|  | --- a/src/index.js | ||||||
|  | +++ b/src/index.js | ||||||
|  | @@ -1,9 +1,8 @@ | ||||||
|  |  import fs from "node:fs"; | ||||||
|  |  import path from "node:path"; | ||||||
|  |  import express from "express"; | ||||||
|  | -import cookieParser from "cookie-parser"; | ||||||
|  |  import cors from "cors"; | ||||||
|  |   | ||||||
|  |  import sql from "./db/index.js"; | ||||||
|  |  import healthRouter from "./routes/health.js"; | ||||||
|  |  import { authJwt } from "./middleware/authJwt.js"; | ||||||
|  | @@ -64,7 +63,7 @@ | ||||||
|  |  // ✅ อยู่หลัง NPM/Reverse proxy → ให้ trust proxy เพื่อให้ cookie secure / proto ทำงานถูก | ||||||
|  |  app.set("trust proxy", 1); | ||||||
|  |   | ||||||
|  | -// CORS แบบกำหนด origin ตามรายการที่อนุญาต + อนุญาต credentials (จำเป็นสำหรับ cookie) | ||||||
|  | +// ✅ CORS สำหรับ Bearer token: ไม่ต้องใช้ credentials (ไม่มีคุกกี้) | ||||||
|  |  app.use( | ||||||
|  |    cors({ | ||||||
|  |      origin(origin, cb) { | ||||||
|  |        if (!origin) return cb(null, true); // server-to-server / curl | ||||||
|  |        return cb(null, ALLOW_ORIGINS.includes(origin)); | ||||||
|  |      }, | ||||||
|  | -    credentials: true, | ||||||
|  | +    credentials: false, | ||||||
|  |      methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], | ||||||
|  | -    allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"], | ||||||
|  | +    allowedHeaders: [ | ||||||
|  | +      "Content-Type", | ||||||
|  | +      "Authorization", | ||||||
|  | +      "X-Requested-With", | ||||||
|  | +      "Accept", | ||||||
|  | +      "Origin", | ||||||
|  | +      "Referer", | ||||||
|  | +      "User-Agent", | ||||||
|  | +      "Cache-Control", | ||||||
|  | +      "Pragma" | ||||||
|  | +    ], | ||||||
|  |      exposedHeaders: ["Content-Disposition", "Content-Length"], | ||||||
|  |    }) | ||||||
|  |  ); | ||||||
|  |  // preflight | ||||||
|  |  app.options( | ||||||
|  |    "*", | ||||||
|  |    cors({ | ||||||
|  |      origin(origin, cb) { | ||||||
|  |        if (!origin) return cb(null, true); | ||||||
|  |        return cb(null, ALLOW_ORIGINS.includes(origin)); | ||||||
|  |      }, | ||||||
|  | -    credentials: true, | ||||||
|  | +    credentials: false, | ||||||
|  |    }) | ||||||
|  |  ); | ||||||
|  |   | ||||||
|  | -app.use(cookieParser()); | ||||||
|  | +// ❌ ไม่ต้อง parse cookie แล้ว (เราไม่ใช้คุกกี้สำหรับ auth) | ||||||
|  | +// app.use(cookieParser()); | ||||||
|  |   | ||||||
|  |  // Payload limits | ||||||
|  |  app.use(express.json({ limit: "10mb" })); | ||||||
|  |  app.use(express.urlencoded({ extended: true, limit: "10mb" })); | ||||||
|  |   | ||||||
| @@ -1,8 +1,11 @@ | |||||||
| .git | .git | ||||||
|  | .vscode | ||||||
|  | .backup | ||||||
| node_modules | node_modules | ||||||
| logs | logs | ||||||
| *.log | *.log | ||||||
| Dockerfile* | Dockerfile*.* | ||||||
|  | *.yml | ||||||
| README*.md | README*.md | ||||||
| coverage | coverage | ||||||
| tmp | tmp | ||||||
|   | |||||||
							
								
								
									
										15
									
								
								backend/.eslintrc.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								backend/.eslintrc.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | { | ||||||
|  |   "env": { | ||||||
|  |     "node": true, | ||||||
|  |     "es2021": true, | ||||||
|  |     "jest": true | ||||||
|  |   }, | ||||||
|  |   "extends": ["eslint:recommended", "plugin:prettier/recommended"], | ||||||
|  |   "parserOptions": { | ||||||
|  |     "ecmaVersion": "latest", | ||||||
|  |     "sourceType": "module" | ||||||
|  |   }, | ||||||
|  |   "rules": { | ||||||
|  |     "prettier/prettier": "warn" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										3
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  |  | ||||||
|  | # Ignore Nginx Proxy Manager data | ||||||
|  | /npm/ | ||||||
							
								
								
									
										7
									
								
								backend/.prettierrc.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								backend/.prettierrc.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | |||||||
|  | { | ||||||
|  |   "semi": true, | ||||||
|  |   "singleQuote": true, | ||||||
|  |   "trailingComma": "es5", | ||||||
|  |   "arrowParens": "always", | ||||||
|  |   "printWidth": 80 | ||||||
|  | } | ||||||
| @@ -67,3 +67,4 @@ EXPOSE 3001 | |||||||
| HEALTHCHECK --interval=30s --timeout=5s --retries=10 \ | HEALTHCHECK --interval=30s --timeout=5s --retries=10 \ | ||||||
|   CMD wget -qO- http://127.0.0.1:3001/health || exit 1 |   CMD wget -qO- http://127.0.0.1:3001/health || exit 1 | ||||||
| CMD ["node","src/index.js"] | CMD ["node","src/index.js"] | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										159
									
								
								backend/README2.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								backend/README2.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,159 @@ | |||||||
|  | # DMS Backend – คู่มือ Auth & RBAC/ABAC | ||||||
|  |  | ||||||
|  | ## ภาพรวมระบบ | ||||||
|  | Backend ใช้ **Bearer Token** สำหรับการยืนยันตัวตน และตรวจสอบสิทธิ์ด้วย **RBAC (Role-Based Access Control)** ร่วมกับ **ABAC (Attribute-Based Access Control)** | ||||||
|  |  | ||||||
|  | โครงหลักคือ: | ||||||
|  | 1. **authJwt()** → ตรวจสอบ JWT ใน header `Authorization: Bearer ...` | ||||||
|  | 2. **loadPrincipalMw()** → โหลดข้อมูลผู้ใช้ + บทบาท + สิทธิ์ + ขอบเขตโปรเจ็ค/องค์กร | ||||||
|  | 3. **requirePerm()** → ตรวจสอบ `perm_code` จากตาราง `permissions` และบังคับ ABAC (ORG/PROJECT scope) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## การยืนยันตัวตน (Authentication) | ||||||
|  |  | ||||||
|  | ### Frontend ส่งอย่างไร | ||||||
|  | ```http | ||||||
|  | GET /api/projects | ||||||
|  | Authorization: Bearer <access_token> | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | - **ไม่มีการใช้ cookie** (Bearer-only) | ||||||
|  | - ถ้า token หมดอายุ ให้ใช้ `refresh_token` ไปขอใหม่ที่ `/api/auth/refresh` | ||||||
|  |  | ||||||
|  | ### Middleware `authJwt()` | ||||||
|  | - อ่าน `Authorization: Bearer ...` | ||||||
|  | - ตรวจสอบด้วย `JWT_SECRET` | ||||||
|  | - เติม `req.auth = { user_id, username }` | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## การโหลด Principal | ||||||
|  |  | ||||||
|  | ### Middleware `loadPrincipalMw()` | ||||||
|  | - ใช้ `user_id` ไป query DB: | ||||||
|  |   - users, roles, permissions, project_ids, org_ids | ||||||
|  | - สร้าง `req.principal`: | ||||||
|  | ```js | ||||||
|  | { | ||||||
|  |   user_id, username, email, first_name, last_name, org_id, | ||||||
|  |   roles: [{ role_id, role_code, role_name }], | ||||||
|  |   permissions: Set<perm_code>, | ||||||
|  |   project_ids: [..], | ||||||
|  |   org_ids: [..], | ||||||
|  |   is_superadmin: true/false, | ||||||
|  |  | ||||||
|  |   // helper functions | ||||||
|  |   can(code), | ||||||
|  |   canAny(codes[]), | ||||||
|  |   canAll(codes[]), | ||||||
|  |   inProject(pid), | ||||||
|  |   inOrg(oid) | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## การตรวจสอบสิทธิ์ (RBAC + ABAC) | ||||||
|  |  | ||||||
|  | ### Middleware `requirePerm(permCode, { projectParam?, orgParam? })` | ||||||
|  | 1. ตรวจว่า user มี `permCode` หรือเป็น superadmin | ||||||
|  | 2. อ่าน `scope_level` จากตาราง `permissions` | ||||||
|  |    - `GLOBAL`   → มีสิทธิ์ก็พอ | ||||||
|  |    - `ORG`      → ต้องมีสิทธิ์ + อยู่ใน org scope | ||||||
|  |    - `PROJECT`  → ต้องมีสิทธิ์ + อยู่ใน project scope | ||||||
|  | 3. อ่าน `project_id` / `org_id` จาก request (`params`, `query`, `body`) | ||||||
|  | 4. ถ้าไม่ผ่าน → คืน `403 FORBIDDEN` | ||||||
|  |  | ||||||
|  | ### Error response ตัวอย่าง | ||||||
|  | ```json | ||||||
|  | { "error": "FORBIDDEN", "need": "projects.manage" } | ||||||
|  | { "error": "FORBIDDEN_PROJECT", "project_id": 12 } | ||||||
|  | { "error": "FORBIDDEN_ORG", "org_id": 5 } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## รูปแบบที่แนะนำ | ||||||
|  |  | ||||||
|  | ### List (PROJECT scope) | ||||||
|  | ```js | ||||||
|  | r.get("/", requirePerm("documents.view", { projectParam: "project_id" }), async (req, res) => { | ||||||
|  |   const P = req.principal; | ||||||
|  |   const { project_id } = req.query; | ||||||
|  |   const cond = [], params = []; | ||||||
|  |  | ||||||
|  |   if (!P.is_superadmin) { | ||||||
|  |     if (project_id) { | ||||||
|  |       if (!P.inProject(+project_id)) return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|  |       cond.push("project_id=?"); params.push(+project_id); | ||||||
|  |     } else if (P.project_ids?.length) { | ||||||
|  |       cond.push(`project_id IN (${P.project_ids.map(()=>"?").join(",")})`); | ||||||
|  |       params.push(...P.project_ids); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; | ||||||
|  |   const [rows] = await sql.query(`SELECT * FROM documents ${where} ORDER BY created_at DESC LIMIT 50`, params); | ||||||
|  |   res.json(rows); | ||||||
|  | }); | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Item (PROJECT scope) | ||||||
|  | ```js | ||||||
|  | r.delete("/:id", requirePerm("drawings.delete"), async (req, res) => { | ||||||
|  |   const id = +req.params.id; | ||||||
|  |   const [[row]] = await sql.query("SELECT project_id FROM drawings WHERE id=?", [id]); | ||||||
|  |   if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|  |   if (!req.principal.is_superadmin && !req.principal.inProject(row.project_id)) { | ||||||
|  |     return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|  |   } | ||||||
|  |   await sql.query("DELETE FROM drawings WHERE id=?", [id]); | ||||||
|  |   res.json({ ok: 1 }); | ||||||
|  | }); | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## การแมปสิทธิ์ (perm_code) | ||||||
|  |  | ||||||
|  | | หมวด         | สิทธิ์ (perm_code)                          | scope      | | ||||||
|  | |--------------|---------------------------------------------|------------| | ||||||
|  | | Organizations| `organizations.view`, `organizations.manage`| GLOBAL     | | ||||||
|  | | Projects     | `projects.view`, `projects.manage`          | ORG        | | ||||||
|  | | Drawings     | `drawings.view`, `drawings.upload`, `drawings.delete` | PROJECT | | ||||||
|  | | Documents    | `documents.view`, `documents.manage`        | PROJECT    | | ||||||
|  | | RFAs         | `rfas.view`, `rfas.create`, `rfas.respond`, `rfas.delete` | PROJECT | | ||||||
|  | | Correspondences | `corr.view`, `corr.manage`               | PROJECT    | | ||||||
|  | | Transmittals | `transmittals.manage`                       | PROJECT    | | ||||||
|  | | Reports      | `reports.view`                              | GLOBAL     | | ||||||
|  | | Settings     | `settings.manage`                           | GLOBAL     | | ||||||
|  | | Admin        | `admin.access`                              | ORG        | | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Checklist สำหรับเพิ่ม Endpoint ใหม่ | ||||||
|  |  | ||||||
|  | 1. เลือก `perm_code` ที่ตรงกับ seed | ||||||
|  | 2. ใส่ `requirePerm("<perm>", { projectParam?: "...", orgParam?: "..." })` | ||||||
|  | 3. ถ้าเป็น GET/PUT/DELETE record เดี่ยว → ตรวจสอบซ้ำด้วย `inProject`/`inOrg` | ||||||
|  | 4. ใช้ `callProc("sp_name", [...])` ถ้า endpoint เรียก Stored Procedure | ||||||
|  | 5. ฝั่ง FE ต้องส่ง `Authorization: Bearer ...` และ parameter `project_id`/`org_id` ที่จำเป็น | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## Pitfalls ที่พบบ่อย | ||||||
|  | - ลืมส่ง `project_id` ในคำขอ → 403 | ||||||
|  | - อ้าง perm_code ผิด (เช่น `document.view` แทน `documents.view`) | ||||||
|  | - ไม่กรอง project/org scope ใน query → ข้อมูลรั่ว | ||||||
|  | - ลืมเช็ค item-level ABAC → ข้ามขอบเขตได้ | ||||||
|  | - ปน cookie-auth เข้ามา → backend จะไม่รองรับแล้ว | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## TL;DR | ||||||
|  | - ทุกคำขอ → Bearer Token   | ||||||
|  | - `authJwt()` → ใส่ `req.auth`   | ||||||
|  | - `loadPrincipalMw()` → ใส่ `req.principal` (roles, perms, scope)   | ||||||
|  | - `requirePerm()` → บังคับ RBAC + ABAC อัตโนมัติ   | ||||||
|  | - เพิ่ม endpoint ใหม่ → ใช้ checklist ข้างบน | ||||||
							
								
								
									
										77
									
								
								backend/docker-compose.yml
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										77
									
								
								backend/docker-compose.yml
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,77 @@ | |||||||
|  | # File: backend/docker-compose.yml | ||||||
|  | # DMS Container v0_8_0 แยก service/ lcbp3-backend | ||||||
|  | x-restart: &restart_policy | ||||||
|  |   restart: unless-stopped | ||||||
|  |  | ||||||
|  | x-logging: &default_logging | ||||||
|  |   logging: | ||||||
|  |     driver: "json-file" | ||||||
|  |     options: | ||||||
|  |       max-size: "10m" | ||||||
|  |       max-file: "5" | ||||||
|  | volumes: | ||||||
|  |   backend_node_modules: | ||||||
|  | services: | ||||||
|  |   backend: | ||||||
|  |     <<: [*restart_policy, *default_logging] | ||||||
|  |     image: dms-backend:dev | ||||||
|  |     # pull_policy: never  # <-- FINAL FIX ADDED HERE | ||||||
|  |     container_name: dms_backend | ||||||
|  |     stdin_open: true | ||||||
|  |     tty: true | ||||||
|  |     #user: "node" | ||||||
|  |     user: "1000:1000" | ||||||
|  |     working_dir: /app | ||||||
|  |     deploy: | ||||||
|  |       resources: | ||||||
|  |         limits: | ||||||
|  |           cpus: "2.0" | ||||||
|  |           memory: 1G | ||||||
|  |         reservations: | ||||||
|  |           cpus: "0.25" | ||||||
|  |           memory: 256M | ||||||
|  |     environment: | ||||||
|  |       TZ: "Asia/Bangkok" | ||||||
|  |       CHOKIDAR_USEPOLLING: "1" | ||||||
|  |       CHOKIDAR_INTERVAL: "300" | ||||||
|  |       WATCHPACK_POLLING: "true" | ||||||
|  |       # NODE_ENV: "production" | ||||||
|  |       NODE_ENV: "development" | ||||||
|  |       PORT: "3001" | ||||||
|  |       DB_HOST: "mariadb" | ||||||
|  |       DB_PORT: "3306" | ||||||
|  |       DB_USER: "center" | ||||||
|  |       DB_PASSWORD: "Center#2025" | ||||||
|  |       DB_NAME: "dms" | ||||||
|  |       JWT_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca" | ||||||
|  |       JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca" | ||||||
|  |       JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c" | ||||||
|  |       ACCESS_TTL_MS: "900000" | ||||||
|  |       REFRESH_TTL_MS: "604800000" | ||||||
|  |       JWT_EXPIRES_IN: "12h" | ||||||
|  |       PASSWORD_SALT_ROUNDS: "10" | ||||||
|  |       FRONTEND_ORIGIN: "https://lcbp3.np-dms.work" | ||||||
|  |       CORS_ORIGINS: "https://backend.np-dms.work,http://localhost:3000,http://127.0.0.1:3000,https://lcbp3.np-dms.work" | ||||||
|  |       COOKIE_DOMAIN: ".np-dms.work" | ||||||
|  |       RATE_LIMIT_WINDOW_MS: "900000" | ||||||
|  |       RATE_LIMIT_MAX: "200" | ||||||
|  |       BACKEND_LOG_DIR: "/app/logs" | ||||||
|  |     networks: | ||||||
|  |       lcbp3: {} | ||||||
|  |     volumes: | ||||||
|  |       - "/share/Container/dms/backend/src:/app/src:rw" | ||||||
|  |       # - "/share/Container/dms/backend/package.json:/app/package.json" | ||||||
|  |       # - "/share/Container/dms/backend/package-lock.json:/app/package-lock.json" | ||||||
|  |       - "/share/dms-data:/share/dms-data:rw" | ||||||
|  |       - "/share/Container/dms/logs/backend:/app/logs:rw" | ||||||
|  |       # - "/share/Container/dms/backend/node_modules:/app/node_modules" | ||||||
|  |       - "backend_node_modules:/app/node_modules" | ||||||
|  |     healthcheck: | ||||||
|  |       test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3001/health"] | ||||||
|  |       interval: 10s | ||||||
|  |       timeout: 5s | ||||||
|  |       retries: 30 | ||||||
|  |  | ||||||
|  | networks: | ||||||
|  |   lcbp3: | ||||||
|  |     external: true | ||||||
							
								
								
									
										6069
									
								
								backend/package-lock.json
									
									
									
										generated
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										6069
									
								
								backend/package-lock.json
									
									
									
										generated
									
									
									
										
										
										Executable file → Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,22 +1,25 @@ | |||||||
| { | { | ||||||
|   "name": "dms-backend", |   "name": "dms-backend", | ||||||
|   "version": "0.6.0", |   "version": "0.8.0", | ||||||
|   "private": true, |   "private": true, | ||||||
|   "type": "module", |   "type": "module", | ||||||
|   "main": "src/index.js", |   "main": "src/index.js", | ||||||
|   "engines": { |   "engines": { | ||||||
|     "node": ">=20.0.0" |     "node": ">=20.0.0" | ||||||
|   }, |   }, | ||||||
|    |  | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "dev": "nodemon --watch src src/index.js", |     "dev": "nodemon --watch src src/index.js", | ||||||
|     "dev:desktop": "node --watch src/index.js", |     "dev:desktop": "node --watch src/index.js", | ||||||
|     "start": "node src/index.js", |     "start": "node src/index.js", | ||||||
|     "lint": "echo 'lint placeholder'", |     "lint": "eslint . --ext .js", | ||||||
|  |     "lint:fix": "eslint . --ext .js --fix", | ||||||
|  |     "test": "jest", | ||||||
|  |     "test:watch": "jest --watch", | ||||||
|  |     "test:coverage": "jest --coverage", | ||||||
|  |     "test:watch:coverage": "jest --watch --coverage", | ||||||
|     "health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\"", |     "health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\"", | ||||||
|     "postinstall": "node -e \"console.log('Installed dms-backend %s','0.6.0')\"" |     "postinstall": "node -e \"console.log('Installed dms-backend %s','0.6.0')\"" | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "bcrypt": "5.1.1", |     "bcrypt": "5.1.1", | ||||||
|     "bcryptjs": "^2.4.3", |     "bcryptjs": "^2.4.3", | ||||||
| @@ -35,6 +38,12 @@ | |||||||
|     "winston": "^3.13.0" |     "winston": "^3.13.0" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "nodemon": "^3.1.10" |     "nodemon": "^3.1.10", | ||||||
|  |     "eslint": "^8.56.0", | ||||||
|  |     "prettier": "^3.1.0", | ||||||
|  |     "eslint-config-prettier": "^9.0.0", | ||||||
|  |     "eslint-plugin-prettier": "^5.0.0", | ||||||
|  |     "jest": "^29.7.0", | ||||||
|  |     "supertest": "^6.3.4" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,25 +0,0 @@ | |||||||
|  |  | ||||||
| { |  | ||||||
|   "name": "dms-backend", |  | ||||||
|   "version": "0.5.0", |  | ||||||
|   "private": true, |  | ||||||
|   "type": "module", |  | ||||||
|   "scripts": { |  | ||||||
|     "dev": "node --env-file=../.env src/index.js", |  | ||||||
|     "start": "node src/index.js", |  | ||||||
|     "health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\"" |  | ||||||
|   }, |  | ||||||
|   "dependencies": { |  | ||||||
|     "bcrypt": "5.1.1", |  | ||||||
|     "cors": "2.8.5", |  | ||||||
|     "dotenv": "16.4.5", |  | ||||||
|     "express": "4.19.2", |  | ||||||
|     "express-rate-limit": "7.4.0", |  | ||||||
|     "helmet": "7.1.0", |  | ||||||
|     "jsonwebtoken": "9.0.2", |  | ||||||
|     "mariadb": "3.3.1", |  | ||||||
|     "morgan": "1.10.0", |  | ||||||
|     "sequelize": "6.37.3" |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @@ -1,38 +0,0 @@ | |||||||
| { |  | ||||||
|   "name": "dms-backend", |  | ||||||
|   "version": "0.6.0", |  | ||||||
|   "private": true, |  | ||||||
|   "type": "module", |  | ||||||
|   "main": "src/index.js", |  | ||||||
|   "engines": { |  | ||||||
|     "node": ">=20.0.0" |  | ||||||
|   }, |  | ||||||
|   "scripts": { |  | ||||||
|     "dev": "nodemon --watch src src/index.js", |  | ||||||
|     "start": "node src/index.js", |  | ||||||
|     "lint": "echo 'lint placeholder'", |  | ||||||
|     "health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\"", |  | ||||||
|     "postinstall": "node -e \"console.log('Installed dms-backend %s','0.6.0')\"" |  | ||||||
|   }, |  | ||||||
|  |  | ||||||
|   "dependencies": { |  | ||||||
|     "bcrypt": "5.1.1", |  | ||||||
|     "bcryptjs": "^2.4.3", |  | ||||||
|     "cookie-parser": "^1.4.7", |  | ||||||
|     "cors": "2.8.5", |  | ||||||
|     "dotenv": "16.4.5", |  | ||||||
|     "express": "^4.21.2", |  | ||||||
|     "express-rate-limit": "7.4.0", |  | ||||||
|     "helmet": "7.1.0", |  | ||||||
|     "jsonwebtoken": "9.0.2", |  | ||||||
|     "mariadb": "3.3.1", |  | ||||||
|     "morgan": "^1.10.1", |  | ||||||
|     "multer": "^2.0.2", |  | ||||||
|     "mysql2": "^3.11.0", |  | ||||||
|     "sequelize": "6.37.3", |  | ||||||
|     "winston": "^3.13.0" |  | ||||||
|   }, |  | ||||||
|   "devDependencies": { |  | ||||||
|     "nodemon": "^3.1.10" |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -1,24 +1,45 @@ | |||||||
| export const config = { | // FILE: backend/src/config.js | ||||||
|   PORT: Number(process.env.BACKEND_PORT || 3001), | // Centralized configuration (ESM) | ||||||
|   DB: { |  | ||||||
|     HOST: process.env.DB_HOST || 'mariadb', | const toInt = (v, d) => { | ||||||
|     PORT: Number(process.env.DB_PORT || 3306), |   const n = Number(v); | ||||||
|     USER: process.env.DB_USER || 'center', |   return Number.isFinite(n) ? n : d; | ||||||
|     PASS: process.env.DB_PASSWORD || 'Center#2025', |  | ||||||
|     NAME: process.env.DB_NAME || 'dms', |  | ||||||
|   }, |  | ||||||
|   JWT: { |  | ||||||
|     SECRET: process.env.JWT_SECRET || '8b0df02e4aee9f9f79a4f2d8ba77b0b82c1ee3446b68cb0bae94ab54d60f8d9e', |  | ||||||
|     EXPIRES_IN: process.env.JWT_EXPIRES_IN || '8h', |  | ||||||
|     REFRESH_SECRET: process.env.JWT_REFRESH_SECRET || '31r2Wp59E8JukXjYiMopzoHDxJBLVefjR9YOOHjk62R5PbFVXzbx2Wjyc9weVDgK', |  | ||||||
|     REFRESH_EXPIRES_IN: process.env.JWT_REFRESH_EXPIRES_IN || '7d', |  | ||||||
|   }, |  | ||||||
|   SECURITY: { |  | ||||||
|     RATE_LIMIT_WINDOW_MS: Number(process.env.RATE_LIMIT_WINDOW_MS || 60_000), |  | ||||||
|     RATE_LIMIT_MAX: Number(process.env.RATE_LIMIT_MAX || 100), |  | ||||||
|   }, |  | ||||||
|   CORS_ORIGINS: (process.env.CORS_ALLOWLIST || '') |  | ||||||
|     .split(',') |  | ||||||
|     .map(s => s.trim()) |  | ||||||
|     .filter(Boolean), |  | ||||||
| }; | }; | ||||||
|  | const parseAllowlist = (s) => | ||||||
|  |   String(s || "") | ||||||
|  |     .split(",") | ||||||
|  |     .map((x) => x.trim()) | ||||||
|  |     .filter(Boolean); | ||||||
|  |  | ||||||
|  | export const config = { | ||||||
|  |   PORT: toInt(process.env.PORT ?? process.env.BACKEND_PORT, 3001), | ||||||
|  |  | ||||||
|  |   DB: { | ||||||
|  |     HOST: process.env.DB_HOST || "mariadb", | ||||||
|  |     PORT: toInt(process.env.DB_PORT, 3306), | ||||||
|  |     USER: process.env.DB_USER || "center", | ||||||
|  |     PASS: process.env.DB_PASSWORD || "Center#2025", | ||||||
|  |     NAME: process.env.DB_NAME || "dms", | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   JWT: { | ||||||
|  |     SECRET: process.env.JWT_SECRET || "dev-secret", | ||||||
|  |     EXPIRES_IN: process.env.JWT_EXPIRES_IN || "8h", | ||||||
|  |     REFRESH_SECRET: process.env.JWT_REFRESH_SECRET || "dev-refresh", | ||||||
|  |     REFRESH_EXPIRES_IN: process.env.JWT_REFRESH_EXPIRES_IN || "7d", | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   SECURITY: { | ||||||
|  |     RATE_LIMIT_WINDOW_MS: toInt(process.env.RATE_LIMIT_WINDOW_MS, 60_000), | ||||||
|  |     RATE_LIMIT_MAX: toInt(process.env.RATE_LIMIT_MAX, 100), | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   CORS_ORIGINS: parseAllowlist( | ||||||
|  |     process.env.CORS_ALLOWLIST || | ||||||
|  |       // เผื่อ dev ทั่วไป | ||||||
|  |       "http://localhost:3000,http://127.0.0.1:3000" | ||||||
|  |   ), | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // เผื่อไฟล์ไหน import แบบ default | ||||||
|  | export default config; | ||||||
|   | |||||||
| @@ -1,45 +1,60 @@ | |||||||
| // ============================================================= |  | ||||||
| // FILE: src/config/permissions.js | // FILE: src/config/permissions.js | ||||||
| // Purpose: Map permission_code to your seed naming convention. | // Reference enum สำหรับฝั่งโค้ด/ฟรอนต์เท่านั้น | ||||||
| // - Choose one PROFILE (by SEED_PROFILE env) or edit inline to match exactly | // แหล่งจริงของ scope อ่านจาก DB: permissions.scope_level (ผ่าน requirePerm()) | ||||||
| // what's in 06_dms_v0_5_0_data_v5_1_seed_users.sql |  | ||||||
| // ============================================================= |  | ||||||
|  |  | ||||||
|  | const PERM = { | ||||||
| const V5_DOT = { |   organizations: { | ||||||
| organization: { read: 'organization.read' }, |     view: "organizations.view", | ||||||
| project: { read: 'project.read', create: 'project.create', update: 'project.update', delete: 'project.delete' }, |     manage: "organizations.manage", | ||||||
| correspondence: { read: 'correspondence.read', create: 'correspondence.create', update: 'correspondence.update', delete: 'correspondence.delete', upload: 'correspondence.upload' }, |   }, | ||||||
| rfa: { read: 'rfa.read', create: 'rfa.create', update: 'rfa.update', delete: 'rfa.delete', upload: 'rfa.upload' }, |   projects: { | ||||||
| drawing: { read: 'drawing.read', create: 'drawing.create', update: 'drawing.update', delete: 'drawing.delete', upload: 'drawing.upload' }, |     view: "projects.view", | ||||||
| transmittal: { read: 'transmittal.read', create: 'transmittal.create', update: 'transmittal.update', delete: 'transmittal.delete', upload: 'transmittal.upload' }, |     manage: "projects.manage", | ||||||
| contract: { read: 'contract.read', create: 'contract.create', update: 'contract.update', delete: 'contract.delete' }, |     partiesManage: "project_parties.manage", | ||||||
| contract_dwg: { read: 'contract_dwg.read', create: 'contract_dwg.create', update: 'contract_dwg.update', delete: 'contract_dwg.delete' }, |   }, | ||||||
| category: { read: 'category.read', create: 'category.create', update: 'category.update', delete: 'category.delete' }, |   drawings: { | ||||||
| volume: { read: 'volume.read', create: 'volume.create', update: 'volume.update', delete: 'volume.delete' }, |     view: "drawings.view", | ||||||
| permission: { read: 'permission.read' }, |     upload: "drawings.upload", | ||||||
| user: { read: 'user.read' }, |     delete: "drawings.delete", | ||||||
|  |   }, | ||||||
|  |   documents: { | ||||||
|  |     view: "documents.view", | ||||||
|  |     manage: "documents.manage", | ||||||
|  |   }, | ||||||
|  |   materials: { | ||||||
|  |     view: "materials.view", | ||||||
|  |     manage: "materials.manage", | ||||||
|  |   }, | ||||||
|  |   ms: { | ||||||
|  |     view: "ms.view", | ||||||
|  |     manage: "ms.manage", | ||||||
|  |   }, | ||||||
|  |   rfas: { | ||||||
|  |     view: "rfas.view", | ||||||
|  |     create: "rfas.create", | ||||||
|  |     respond: "rfas.respond", | ||||||
|  |     delete: "rfas.delete", | ||||||
|  |   }, | ||||||
|  |   correspondences: { | ||||||
|  |     view: "corr.view", | ||||||
|  |     manage: "corr.manage", | ||||||
|  |   }, | ||||||
|  |   transmittals: { | ||||||
|  |     manage: "transmittals.manage", | ||||||
|  |   }, | ||||||
|  |   circulations: { | ||||||
|  |     manage: "cirs.manage", | ||||||
|  |   }, | ||||||
|  |   admin: { | ||||||
|  |     access: "admin.access", | ||||||
|  |   }, | ||||||
|  |   reports: { | ||||||
|  |     view: "reports.view", | ||||||
|  |   }, | ||||||
|  |   settings: { | ||||||
|  |     manage: "settings.manage", | ||||||
|  |   }, | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export { PERM }; | ||||||
| const V5_SNAKE = { |  | ||||||
| organization: { read: 'organization_read' }, |  | ||||||
| project: { read: 'project_read', create: 'project_create', update: 'project_update', delete: 'project_delete' }, |  | ||||||
| correspondence: { read: 'correspondence_read', create: 'correspondence_create', update: 'correspondence_update', delete: 'correspondence_delete', upload: 'correspondence_upload' }, |  | ||||||
| rfa: { read: 'rfa_read', create: 'rfa_create', update: 'rfa_update', delete: 'rfa_delete', upload: 'rfa_upload' }, |  | ||||||
| drawing: { read: 'drawing_read', create: 'drawing_create', update: 'drawing_update', delete: 'drawing_delete', upload: 'drawing_upload' }, |  | ||||||
| transmittal: { read: 'transmittal_read', create: 'transmittal_create', update: 'transmittal_update', delete: 'transmittal_delete', upload: 'transmittal_upload' }, |  | ||||||
| contract: { read: 'contract_read', create: 'contract_create', update: 'contract_update', delete: 'contract_delete' }, |  | ||||||
| contract_dwg: { read: 'contract_dwg_read', create: 'contract_dwg_create', update: 'contract_dwg_update', delete: 'contract_dwg_delete' }, |  | ||||||
| category: { read: 'category_read', create: 'category_create', update: 'category_update', delete: 'category_delete' }, |  | ||||||
| volume: { read: 'volume_read', create: 'volume_create', update: 'volume_update', delete: 'volume_delete' }, |  | ||||||
| permission: { read: 'permission_read' }, |  | ||||||
| user: { read: 'user_read' }, |  | ||||||
| }; |  | ||||||
|  |  | ||||||
|  |  | ||||||
| const PROFILE = (process.env.SEED_PROFILE || 'V5_DOT').toUpperCase(); |  | ||||||
|  |  | ||||||
|  |  | ||||||
| export const PERM = PROFILE === 'V5_SNAKE' ? V5_SNAKE : V5_DOT; |  | ||||||
| export default PERM; | export default PERM; | ||||||
							
								
								
									
										39
									
								
								backend/src/db/index copy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								backend/src/db/index copy.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | |||||||
|  | // FILE: backend/src/db/index.js (ESM) | ||||||
|  | import mysql from "mysql2/promise"; | ||||||
|  |  | ||||||
|  | const { | ||||||
|  |   DB_HOST = "mariadb", | ||||||
|  |   DB_PORT = "3306", | ||||||
|  |   DB_USER = "center", | ||||||
|  |   DB_PASSWORD = "Center#2025", | ||||||
|  |   DB_NAME = "dms", | ||||||
|  |   DB_CONN_LIMIT = "10", | ||||||
|  | } = process.env; | ||||||
|  |  | ||||||
|  | const pool = mysql.createPool({ | ||||||
|  |   host: DB_HOST, | ||||||
|  |   port: Number(DB_PORT), | ||||||
|  |   user: DB_USER, | ||||||
|  |   password: DB_PASSWORD, | ||||||
|  |   database: DB_NAME, | ||||||
|  |   connectionLimit: Number(DB_CONN_LIMIT), | ||||||
|  |   waitForConnections: true, | ||||||
|  |   namedPlaceholders: true, | ||||||
|  |   dateStrings: true, // คงวันที่เป็น string | ||||||
|  |   timezone: "Z", // ใช้ UTC | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * เรียก Stored Procedure แบบง่าย | ||||||
|  |  * @param {string} procName ชื่อโปรซีเยอร์ เช่น "sp_rfa_create_with_items" | ||||||
|  |  * @param {Array<any>} params ลำดับพารามิเตอร์ | ||||||
|  |  * @returns {Promise<any>} rows จาก CALL | ||||||
|  |  */ | ||||||
|  | export async function callProc(procName, params = []) { | ||||||
|  |   const placeholders = params.map(() => "?").join(","); | ||||||
|  |   const sql = `CALL ${procName}(${placeholders})`; | ||||||
|  |   const [rows] = await pool.query(sql, params); | ||||||
|  |   return rows; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default pool; // ใช้ sql.query(...) ได้ตามที่ routes เรียกอยู่ | ||||||
| @@ -1,13 +1,13 @@ | |||||||
| // ESM | // FILE: backend/src/db/index.js (ESM) | ||||||
| import mysql from 'mysql2/promise'; | import mysql from "mysql2/promise"; | ||||||
|  |  | ||||||
| const { | const { | ||||||
|   DB_HOST = 'mariadb', |   DB_HOST = "mariadb", | ||||||
|   DB_PORT = '3306', |   DB_PORT = "3306", | ||||||
|   DB_USER = 'center', |   DB_USER = "center", | ||||||
|   DB_PASSWORD = 'Center#2025', |   DB_PASSWORD = "Center#2025", | ||||||
|   DB_NAME = 'dms', |   DB_NAME = "dms", | ||||||
|   DB_CONN_LIMIT = '10', |   DB_CONN_LIMIT = "10", | ||||||
| } = process.env; | } = process.env; | ||||||
|  |  | ||||||
| const pool = mysql.createPool({ | const pool = mysql.createPool({ | ||||||
| @@ -17,21 +17,23 @@ const pool = mysql.createPool({ | |||||||
|   password: DB_PASSWORD, |   password: DB_PASSWORD, | ||||||
|   database: DB_NAME, |   database: DB_NAME, | ||||||
|   connectionLimit: Number(DB_CONN_LIMIT), |   connectionLimit: Number(DB_CONN_LIMIT), | ||||||
|   waitForConnections: true, // Recommended for handling connection spikes |   waitForConnections: true, | ||||||
|   namedPlaceholders: true, |   namedPlaceholders: true, | ||||||
|   dateStrings: true, // Keep dates as strings |   dateStrings: true, // คงวันที่เป็น string | ||||||
|   timezone: 'Z', // Store and retrieve dates in UTC |   timezone: "Z", // ใช้ UTC | ||||||
| }); | }); | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Executes a SQL query with parameters. |  * เรียก Stored Procedure แบบง่าย | ||||||
|  * @param {string} sql The SQL query string. |  * @param {string} procName ชื่อโปรซีเยอร์ เช่น "sp_rfa_create_with_items" | ||||||
|  * @param {object} [params={}] The parameters to bind to the query. |  * @param {Array<any>} params ลำดับพารามิเตอร์ | ||||||
|  * @returns {Promise<any[]>} A promise that resolves to an array of rows. |  * @returns {Promise<any>} rows จาก CALL | ||||||
|  */ |  */ | ||||||
| export async function query(sql, params = {}) { | export async function callProc(procName, params = []) { | ||||||
|   const [rows] = await pool.execute(sql, params); |   const placeholders = params.map(() => "?").join(","); | ||||||
|  |   const sql = `CALL ${procName}(${placeholders})`; | ||||||
|  |   const [rows] = await pool.query(sql, params); | ||||||
|   return rows; |   return rows; | ||||||
| } | } | ||||||
|  |  | ||||||
| export default pool; | export default pool; // ใช้ sql.query(...) ได้ตามที่ routes เรียกอยู่ | ||||||
|   | |||||||
							
								
								
									
										71
									
								
								backend/src/db/sequelize copy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								backend/src/db/sequelize copy.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | |||||||
|  | // FILE: backend/src/db/sequelize.js | ||||||
|  | // “lazy-load” ตาม env ปลอดภัยกว่า และยังคง dbReady() ให้เรียกทดสอบได้ | ||||||
|  | // ใช้ได้เมื่อจำเป็น (เช่น งาน admin tool เฉพาะกิจ) | ||||||
|  | // ตั้ง ENABLE_SEQUELIZE=1 เพื่อเปิดใช้ Model loader; ไม่งั้นจะเป็นโหมดเบา ๆ | ||||||
|  | import { Sequelize } from "sequelize"; | ||||||
|  | import { config } from "../config.js"; | ||||||
|  |  | ||||||
|  | export const sequelize = new Sequelize( | ||||||
|  |   config.DB.NAME, | ||||||
|  |   config.DB.USER, | ||||||
|  |   config.DB.PASS, | ||||||
|  |   { | ||||||
|  |     host: config.DB.HOST, | ||||||
|  |     port: config.DB.PORT, | ||||||
|  |     dialect: "mariadb", | ||||||
|  |     logging: false, | ||||||
|  |     dialectOptions: { timezone: "Z" }, | ||||||
|  |     define: { freezeTableName: true, underscored: false, timestamps: false }, | ||||||
|  |     pool: { max: 10, min: 0, idle: 10000 }, | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | export let User = null; | ||||||
|  | export let Role = null; | ||||||
|  | export let Permission = null; | ||||||
|  | export let UserRole = null; | ||||||
|  | export let RolePermission = null; | ||||||
|  |  | ||||||
|  | if (process.env.ENABLE_SEQUELIZE === "1") { | ||||||
|  |   // โหลดโมเดลแบบ on-demand เพื่อลดความเสี่ยง runtime หากไฟล์โมเดลไม่มี | ||||||
|  |   const mdlUser = await import("./models/User.js").catch(() => null); | ||||||
|  |   const mdlRole = await import("./models/Role.js").catch(() => null); | ||||||
|  |   const mdlPerm = await import("./models/Permission.js").catch(() => null); | ||||||
|  |   const mdlUR = await import("./models/UserRole.js").catch(() => null); | ||||||
|  |   const mdlRP = await import("./models/RolePermission.js").catch(() => null); | ||||||
|  |  | ||||||
|  |   if (mdlUser?.default) User = mdlUser.default(sequelize); | ||||||
|  |   if (mdlRole?.default) Role = mdlRole.default(sequelize); | ||||||
|  |   if (mdlPerm?.default) Permission = mdlPerm.default(sequelize); | ||||||
|  |   if (mdlUR?.default) UserRole = mdlUR.default(sequelize); | ||||||
|  |   if (mdlRP?.default) RolePermission = mdlRP.default(sequelize); | ||||||
|  |  | ||||||
|  |   if (User && Role && Permission && UserRole && RolePermission) { | ||||||
|  |     User.belongsToMany(Role, { | ||||||
|  |       through: UserRole, | ||||||
|  |       foreignKey: "user_id", | ||||||
|  |       otherKey: "role_id", | ||||||
|  |     }); | ||||||
|  |     Role.belongsToMany(User, { | ||||||
|  |       through: UserRole, | ||||||
|  |       foreignKey: "role_id", | ||||||
|  |       otherKey: "user_id", | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     Role.belongsToMany(Permission, { | ||||||
|  |       through: RolePermission, | ||||||
|  |       foreignKey: "role_id", | ||||||
|  |       otherKey: "permission_id", | ||||||
|  |     }); | ||||||
|  |     Permission.belongsToMany(Role, { | ||||||
|  |       through: RolePermission, | ||||||
|  |       foreignKey: "permission_id", | ||||||
|  |       otherKey: "role_id", | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export async function dbReady() { | ||||||
|  |   // โหมดเบา ๆ: แค่ทดสอบเชื่อมต่อ | ||||||
|  |   await sequelize.authenticate(); | ||||||
|  | } | ||||||
							
								
								
									
										74
									
								
								backend/src/db/sequelize.js
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						
									
										74
									
								
								backend/src/db/sequelize.js
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							| @@ -1,5 +1,9 @@ | |||||||
| import { Sequelize } from 'sequelize'; | // FILE: backend/src/db/sequelize.js | ||||||
| import { config } from '../config.js'; | // “lazy-load” ตาม env ปลอดภัยกว่า และยังคง dbReady() ให้เรียกทดสอบได้ | ||||||
|  | // ใช้ได้เมื่อจำเป็น (เช่น งาน admin tool เฉพาะกิจ) | ||||||
|  | // ตั้ง ENABLE_SEQUELIZE=1 เพื่อเปิดใช้ Model loader; ไม่งั้นจะเป็นโหมดเบา ๆ | ||||||
|  | import { Sequelize } from "sequelize"; | ||||||
|  | import { config } from "../config.js"; | ||||||
|  |  | ||||||
| export const sequelize = new Sequelize( | export const sequelize = new Sequelize( | ||||||
|   config.DB.NAME, |   config.DB.NAME, | ||||||
| @@ -8,36 +12,60 @@ export const sequelize = new Sequelize( | |||||||
|   { |   { | ||||||
|     host: config.DB.HOST, |     host: config.DB.HOST, | ||||||
|     port: config.DB.PORT, |     port: config.DB.PORT, | ||||||
|     dialect: 'mariadb', |     dialect: "mariadb", | ||||||
|     logging: false, |     logging: false, | ||||||
|     dialectOptions: { timezone: 'Z' }, |     dialectOptions: { timezone: "Z" }, | ||||||
|     define: { |     define: { freezeTableName: true, underscored: false, timestamps: false }, | ||||||
|       freezeTableName: true, |  | ||||||
|       underscored: false, |  | ||||||
|       timestamps: false, |  | ||||||
|     }, |  | ||||||
|     pool: { max: 10, min: 0, idle: 10000 }, |     pool: { max: 10, min: 0, idle: 10000 }, | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| import UserModel from './models/User.js'; | export let User = null; | ||||||
| import RoleModel from './models/Role.js'; | export let Role = null; | ||||||
| import PermissionModel from './models/Permission.js'; | export let Permission = null; | ||||||
| import UserRoleModel from './models/UserRole.js'; | export let UserRole = null; | ||||||
| import RolePermissionModel from './models/RolePermission.js'; | export let RolePermission = null; | ||||||
|  |  | ||||||
| export const User = UserModel(sequelize); | if (process.env.ENABLE_SEQUELIZE === "1") { | ||||||
| export const Role = RoleModel(sequelize); |   // โหลดโมเดลแบบ on-demand เพื่อลดความเสี่ยง runtime หากไฟล์โมเดลไม่มี | ||||||
| export const Permission = PermissionModel(sequelize); |   const mdlUser = await import("./models/User.js").catch(() => null); | ||||||
| export const UserRole = UserRoleModel(sequelize); |   const mdlRole = await import("./models/Role.js").catch(() => null); | ||||||
| export const RolePermission = RolePermissionModel(sequelize); |   const mdlPerm = await import("./models/Permission.js").catch(() => null); | ||||||
|  |   const mdlUR = await import("./models/UserRole.js").catch(() => null); | ||||||
|  |   const mdlRP = await import("./models/RolePermission.js").catch(() => null); | ||||||
|  |  | ||||||
| User.belongsToMany(Role, { through: UserRole, foreignKey: 'user_id', otherKey: 'role_id' }); |   if (mdlUser?.default) User = mdlUser.default(sequelize); | ||||||
| Role.belongsToMany(User, { through: UserRole, foreignKey: 'role_id', otherKey: 'user_id' }); |   if (mdlRole?.default) Role = mdlRole.default(sequelize); | ||||||
|  |   if (mdlPerm?.default) Permission = mdlPerm.default(sequelize); | ||||||
|  |   if (mdlUR?.default) UserRole = mdlUR.default(sequelize); | ||||||
|  |   if (mdlRP?.default) RolePermission = mdlRP.default(sequelize); | ||||||
|  |  | ||||||
| Role.belongsToMany(Permission, { through: RolePermission, foreignKey: 'role_id', otherKey: 'permission_id' }); |   if (User && Role && Permission && UserRole && RolePermission) { | ||||||
| Permission.belongsToMany(Role, { through: RolePermission, foreignKey: 'permission_id', otherKey: 'role_id' }); |     User.belongsToMany(Role, { | ||||||
|  |       through: UserRole, | ||||||
|  |       foreignKey: "user_id", | ||||||
|  |       otherKey: "role_id", | ||||||
|  |     }); | ||||||
|  |     Role.belongsToMany(User, { | ||||||
|  |       through: UserRole, | ||||||
|  |       foreignKey: "role_id", | ||||||
|  |       otherKey: "user_id", | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     Role.belongsToMany(Permission, { | ||||||
|  |       through: RolePermission, | ||||||
|  |       foreignKey: "role_id", | ||||||
|  |       otherKey: "permission_id", | ||||||
|  |     }); | ||||||
|  |     Permission.belongsToMany(Role, { | ||||||
|  |       through: RolePermission, | ||||||
|  |       foreignKey: "permission_id", | ||||||
|  |       otherKey: "role_id", | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
| export async function dbReady() { | export async function dbReady() { | ||||||
|  |   // โหมดเบา ๆ: แค่ทดสอบเชื่อมต่อ | ||||||
|   await sequelize.authenticate(); |   await sequelize.authenticate(); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,200 +1,173 @@ | |||||||
| // src/index.js  (ESM) | // FILE: backend/src/index.js  (ESM) ไฟล์ฉบับ “Bearer-only” | ||||||
| // ------------------- | // FILE: src/index.js  (ESM) | ||||||
| // Node >= 18, Express 4/5 compatible | import fs from "node:fs"; | ||||||
|  | import express from "express"; | ||||||
|  | import cors from "cors"; | ||||||
|  |  | ||||||
| import fs from 'node:fs'; | import sql from "./db/index.js"; | ||||||
| import path from 'node:path'; | import healthRouter from "./routes/health.js"; | ||||||
| import express from 'express'; | import { authJwt } from "./middleware/authJwt.js"; | ||||||
| import cookieParser from 'cookie-parser'; | import { loadPrincipalMw } from "./middleware/loadPrincipal.js"; | ||||||
| import cors from 'cors'; |  | ||||||
|  |  | ||||||
| import sql from './db/index.js'; |  | ||||||
| import healthRouter from './routes/health.js'; |  | ||||||
| import { authJwt } from './middleware/authJwt.js'; |  | ||||||
| import { loadPrincipalMw } from './middleware/loadPrincipal.js'; |  | ||||||
|  |  | ||||||
| // ROUTES | // ROUTES | ||||||
| import authRoutes from './routes/auth.js'; | import authRoutes from "./routes/auth.js"; | ||||||
| import lookupRoutes from './routes/lookup.js'; | import lookupRoutes from "./routes/lookup.js"; | ||||||
| import organizationsRoutes from './routes/organizations.js'; | import organizationsRoutes from "./routes/organizations.js"; | ||||||
| import projectsRoutes from './routes/projects.js'; | import projectsRoutes from "./routes/projects.js"; | ||||||
| import correspondencesRoutes from './routes/correspondences.js'; | import correspondencesRoutes from "./routes/correspondences.js"; | ||||||
| import rfasRoutes from './routes/rfas.js'; | import rfasRoutes from "./routes/rfas.js"; | ||||||
| import drawingsRoutes from './routes/drawings.js'; | import drawingsRoutes from "./routes/drawings.js"; | ||||||
| import transmittalsRoutes from './routes/transmittals.js'; | import transmittalsRoutes from "./routes/transmittals.js"; | ||||||
| import contractsRoutes from './routes/contracts.js'; | import contractsRoutes from "./routes/contracts.js"; | ||||||
| import contractDwgRoutes from './routes/contract_dwg.js'; | import contractDwgRoutes from "./routes/contract_dwg.js"; | ||||||
| import categoriesRoutes from './routes/categories.js'; | import categoriesRoutes from "./routes/categories.js"; | ||||||
| import volumesRoutes from './routes/volumes.js'; | import volumesRoutes from "./routes/volumes.js"; | ||||||
| import uploadsRoutes from './routes/uploads.js'; | import uploadsRoutes from "./routes/uploads.js"; | ||||||
| import usersRoutes from './routes/users.js'; | import usersRoutes from "./routes/users.js"; | ||||||
| import permissionsRoutes from './routes/permissions.js'; | import permissionsRoutes from "./routes/permissions.js"; | ||||||
|  |  | ||||||
| // import { requireAuth } from './middleware/requireAuth.js'; |  | ||||||
|  |  | ||||||
| /* ========================== |  | ||||||
|  * CONFIG (ปรับค่านี้ได้) |  | ||||||
|  * ========================== */ |  | ||||||
| // const PORT = Number(process.env.PORT || 7001); |  | ||||||
| const PORT = Number(process.env.PORT || 3001); | const PORT = Number(process.env.PORT || 3001); | ||||||
| const NODE_ENV = process.env.NODE_ENV || 'production'; | const NODE_ENV = process.env.NODE_ENV || "development"; | ||||||
|  |  | ||||||
|  | const FRONTEND_ORIGIN = | ||||||
|  |   process.env.FRONTEND_ORIGIN || "https://lcbp3.np-dms.work"; | ||||||
|  |  | ||||||
| // Origin ของ Frontend (ถ้ามี Nginx ด้านหน้า ให้ใช้โดเมน/พอร์ตของ Frontend) |  | ||||||
| // Origin ของ Frontend (ตั้งผ่าน ENV ในแต่ละสภาพแวดล้อม; dev ใช้ localhost) |  | ||||||
| const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'https://lcbp3.mycloudnas.com'; |  | ||||||
| const ALLOW_ORIGINS = [ | const ALLOW_ORIGINS = [ | ||||||
|   'http://localhost:3000', |   "http://localhost:3000", | ||||||
|   'http://127.0.0.1:3000', |   "http://127.0.0.1:3000", | ||||||
|   FRONTEND_ORIGIN, |   FRONTEND_ORIGIN, | ||||||
|  |   ...(process.env.CORS_ALLOWLIST | ||||||
|  |     ? process.env.CORS_ALLOWLIST.split(",").map((x) => x.trim()).filter(Boolean) | ||||||
|  |     : []), | ||||||
| ].filter(Boolean); | ].filter(Boolean); | ||||||
|  |  | ||||||
| // ที่เก็บ log ภายใน container ถูก bind ไปที่ /share/Container/dms/logs/backend | const LOG_DIR = process.env.BACKEND_LOG_DIR || "/app/logs"; | ||||||
| const LOG_DIR = process.env.BACKEND_LOG_DIR || '/app/logs'; |  | ||||||
|  |  | ||||||
| // สร้างโฟลเดอร์ log ถ้ายังไม่มี (แก้ปัญหา Permission denied ล่วงหน้า: ให้ host map เป็น 775 และ uid=100) |  | ||||||
| try { | try { | ||||||
|   if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); |   if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); | ||||||
| } catch (e) { | } catch (e) { | ||||||
|   console.warn('[WARN] Cannot ensure LOG_DIR:', LOG_DIR, e?.message); |   console.warn("[WARN] Cannot ensure LOG_DIR:", LOG_DIR, e?.message); | ||||||
| } | } | ||||||
|  |  | ||||||
| /* ========================== |  | ||||||
|  * APP INIT |  | ||||||
|  * ========================== */ |  | ||||||
| const app = express(); | const app = express(); | ||||||
|  | app.set("trust proxy", 1); | ||||||
|  |  | ||||||
| // CORS แบบกำหนด origin ตามรายการที่อนุญาต + อนุญาต credentials | // CORS: allow list | ||||||
| app.use(cors({ | app.use( | ||||||
|  |   cors({ | ||||||
|     origin(origin, cb) { |     origin(origin, cb) { | ||||||
|     // อนุญาต server-to-server / curl ที่ไม่มี Origin |       if (!origin) return cb(null, true); // server-to-server / curl | ||||||
|     if (!origin) return cb(null, true); |       cb(null, ALLOW_ORIGINS.includes(origin)); | ||||||
|     return cb(null, ALLOW_ORIGINS.includes(origin)); |  | ||||||
|     }, |     }, | ||||||
|   credentials: true, |     credentials: false, // Bearer-only | ||||||
|   methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], |     methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], | ||||||
|   allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], |     allowedHeaders: [ | ||||||
|   exposedHeaders: ['Content-Disposition', 'Content-Length'], |       "Content-Type", | ||||||
| })); |       "Authorization", | ||||||
| // จัดการ preflight ให้ครบ |       "X-Requested-With", | ||||||
| app.options('*', cors({ |       "Accept", | ||||||
|  |       "Origin", | ||||||
|  |       "Referer", | ||||||
|  |       "User-Agent", | ||||||
|  |       "Cache-Control", | ||||||
|  |       "Pragma", | ||||||
|  |     ], | ||||||
|  |     exposedHeaders: ["Content-Disposition", "Content-Length"], | ||||||
|  |   }) | ||||||
|  | ); | ||||||
|  | app.options( | ||||||
|  |   "*", | ||||||
|  |   cors({ | ||||||
|     origin(origin, cb) { |     origin(origin, cb) { | ||||||
|       if (!origin) return cb(null, true); |       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 | // minimal access log | ||||||
| app.use(express.json({ limit: '10mb' })); |  | ||||||
| app.use(express.urlencoded({ extended: true, limit: '10mb' })); |  | ||||||
|  |  | ||||||
| // Access log (ขั้นต่ำ): พิมพ์ลง stdout ให้ Docker เก็บ; ถ้าต้องการเขียนไฟล์ ให้เปลี่ยนเป็น fs.appendFileSync |  | ||||||
| app.use((req, _res, next) => { | app.use((req, _res, next) => { | ||||||
|   console.log(`[REQ] ${req.method} ${req.originalUrl}`); |   console.log(`[REQ] ${req.method} ${req.originalUrl}`); | ||||||
|   next(); |   next(); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| /* ========================== | // health/info (เปิดทั้ง /health, /livez, /readyz, /info) | ||||||
|  * HEALTH / READY / INFO | app.get("/health", async (_req, res) => { | ||||||
|  * ========================== */ |  | ||||||
| app.get('/health', async (req, res) => { |  | ||||||
|   try { |   try { | ||||||
|     const [[{ now }]] = await sql.query('SELECT NOW() AS now'); |     const [[{ now }]] = await sql.query("SELECT NOW() AS now"); | ||||||
|     return res.json({ status: 'ok', db: 'ok', now }); |     res.json({ status: "ok", db: "ok", now }); | ||||||
|   } catch (e) { |   } catch (e) { | ||||||
|     return res.status(500).json({ status: 'degraded', db: 'fail', error: e?.message }); |     res.status(500).json({ status: "degraded", db: "fail", error: e?.message }); | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
|  | app.get("/livez", (_req, res) => res.send("ok")); | ||||||
| // Kubernetes-style endpoints (ถ้าใช้) | app.get("/readyz", async (_req, res) => { | ||||||
| app.get('/livez', (req, res) => res.send('ok')); |   try { await sql.query("SELECT 1"); res.send("ready"); } | ||||||
| app.get('/readyz', async (req, res) => { |   catch { res.status(500).send("not-ready"); } | ||||||
|   try { |  | ||||||
|     await sql.query('SELECT 1'); |  | ||||||
|     res.send('ready'); |  | ||||||
|   } catch { |  | ||||||
|     res.status(500).send('not-ready'); |  | ||||||
|   } |  | ||||||
| }); | }); | ||||||
|  | app.get("/info", (_req, res) => | ||||||
| // เวอร์ชัน/บิลด์ (เติมจาก ENV ถ้าต้องการ) |  | ||||||
| app.get('/info', (req, res) => { |  | ||||||
|   res.json({ |   res.json({ | ||||||
|     name: 'dms-backend', |     name: "dms-backend", | ||||||
|     env: NODE_ENV, |     env: NODE_ENV, | ||||||
|     version: process.env.APP_VERSION || '0.5.0', |     version: process.env.APP_VERSION || "0.5.0", | ||||||
|     commit: process.env.GIT_COMMIT || undefined, |     commit: process.env.GIT_COMMIT || undefined, | ||||||
|   }); |   }) | ||||||
| }); | ); | ||||||
|  |  | ||||||
| /* ========================== | // ---------- Public (no auth) ---------- | ||||||
|  * PROTECTED API | app.use("/api", healthRouter); | ||||||
|  * ========================== */ | app.use("/api/auth", authRoutes); | ||||||
| // ต้อง auth + principal ก่อนเข้าทุก /api/* |  | ||||||
| app.use('/api', healthRouter); |  | ||||||
| app.use('/api/auth', authRoutes); // login/refresh/logout (ไม่ต้องผ่าน authJwt ทั้งกลุ่ม) |  | ||||||
| app.use('/api', authJwt(), loadPrincipalMw()); // จากนี้ต้องมี JWT + principal |  | ||||||
|  |  | ||||||
| app.use('/api/lookup', lookupRoutes); | // ---------- Protected (Bearer + Principal) ---------- | ||||||
| // โมดูลหลัก | app.use("/api", authJwt(), loadPrincipalMw()); | ||||||
| app.use('/api/organizations', organizationsRoutes); |  | ||||||
| app.use('/api/projects', projectsRoutes); |  | ||||||
| app.use('/api/correspondences', correspondencesRoutes); |  | ||||||
| app.use('/api/rfas', rfasRoutes); |  | ||||||
| app.use('/api/drawings', drawingsRoutes); |  | ||||||
| app.use('/api/transmittals', transmittalsRoutes); |  | ||||||
| app.use('/api/contracts', contractsRoutes); |  | ||||||
| app.use('/api/contract-dwg', contractDwgRoutes); |  | ||||||
| app.use('/api/categories', categoriesRoutes); |  | ||||||
| app.use('/api/volumes', volumesRoutes); |  | ||||||
| app.use('/api/uploads', uploadsRoutes); |  | ||||||
| app.use('/api/users', usersRoutes); |  | ||||||
| app.use('/api/permissions', permissionsRoutes); |  | ||||||
|  |  | ||||||
| /* ========================== | app.use("/api/lookup", lookupRoutes); | ||||||
|  * NOT FOUND & ERROR HANDLERS | app.use("/api/organizations", organizationsRoutes); | ||||||
|  * ========================== */ | app.use("/api/projects", projectsRoutes); | ||||||
| app.use((req, res) => { | app.use("/api/correspondences", correspondencesRoutes); | ||||||
|   res.status(404).json({ error: 'NOT_FOUND', path: req.originalUrl }); | app.use("/api/rfas", rfasRoutes); | ||||||
| }); | app.use("/api/drawings", drawingsRoutes); | ||||||
|  | app.use("/api/transmittals", transmittalsRoutes); | ||||||
|  | app.use("/api/contracts", contractsRoutes); | ||||||
|  | app.use("/api/contract-dwg", contractDwgRoutes); | ||||||
|  | app.use("/api/categories", categoriesRoutes); | ||||||
|  | app.use("/api/volumes", volumesRoutes); | ||||||
|  | app.use("/api/uploads", uploadsRoutes); | ||||||
|  | app.use("/api/users", usersRoutes); | ||||||
|  | app.use("/api/permissions", permissionsRoutes); | ||||||
|  |  | ||||||
| // ต้องมี 4 พารามิเตอร์เพื่อเป็น error handler ใน Express | // 404 / error | ||||||
|  | app.use((req, res) => | ||||||
|  |   res.status(404).json({ error: "NOT_FOUND", path: req.originalUrl }) | ||||||
|  | ); | ||||||
| // eslint-disable-next-line no-unused-vars | // eslint-disable-next-line no-unused-vars | ||||||
| app.use((err, req, res, _next) => { | app.use((err, _req, res, _next) => { | ||||||
|   console.error('[UNHANDLED ERROR]', err); |   console.error("[UNHANDLED ERROR]", err); | ||||||
|   const status = err?.status || 500; |   res.status(err?.status || 500).json({ error: "SERVER_ERROR" }); | ||||||
|   res.status(status).json({ |  | ||||||
|     error: 'SERVER_ERROR', |  | ||||||
|     message: NODE_ENV === 'production' ? undefined : err?.message, |  | ||||||
|   }); |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
| /* ========================== | // START | ||||||
|  * START SERVER |  | ||||||
|  * ========================== */ |  | ||||||
| const server = app.listen(PORT, () => { | const server = app.listen(PORT, () => { | ||||||
|   console.log(`Backend API listening on ${PORT} (env=${NODE_ENV})`); |   console.log(`Backend API listening on ${PORT} (env=${NODE_ENV})`); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| /* ========================== | // Shutdown | ||||||
|  * GRACEFUL SHUTDOWN |  | ||||||
|  * ========================== */ |  | ||||||
| async function shutdown(signal) { | async function shutdown(signal) { | ||||||
|   try { |   try { | ||||||
|     console.log(`[SHUTDOWN] ${signal} received`); |     console.log(`[SHUTDOWN] ${signal} received`); | ||||||
|     await new Promise(resolve => server.close(resolve)); |     await new Promise((resolve) => server.close(resolve)); | ||||||
|     try { await sql.end(); } catch {} |     try { await sql.end(); } catch {} | ||||||
|     console.log('[SHUTDOWN] complete'); |     console.log("[SHUTDOWN] complete"); | ||||||
|     process.exit(0); |     process.exit(0); | ||||||
|   } catch (e) { |   } catch (e) { | ||||||
|     console.error('[SHUTDOWN] error', e); |     console.error("[SHUTDOWN] error", e); | ||||||
|     process.exit(1); |     process.exit(1); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | process.on("SIGTERM", () => shutdown("SIGTERM")); | ||||||
| process.on('SIGTERM', () => shutdown('SIGTERM')); | process.on("SIGINT", () => shutdown("SIGINT")); | ||||||
| process.on('SIGINT', () => shutdown('SIGINT')); |  | ||||||
|  |  | ||||||
| export default app; | export default app; | ||||||
|   | |||||||
| @@ -1,103 +1,43 @@ | |||||||
| import { sequelize } from '../db/sequelize.js'; | // FILE: src/middleware/abac.js | ||||||
| import UPRModel from '../db/models/UserProjectRole.js'; | // ABAC: Attribute-Based Access Control middleware helpers | ||||||
|  | // - Project-scoped access control base on user_project_roles + permissions | ||||||
|  | // - Requires req.user.roles and req.user.permissions to be populated (e.g. via auth.js with enrichment) | ||||||
|  | // - Uses UserProjectRole model to check project membership | ||||||
|  | // Helper ABAC เสริมบางเคส (ถ้าต้องการฟิลเตอร์/บังคับ project_id ตรง ๆ) | ||||||
|  | // หมายเหตุ: โดยหลักแล้วคุณควรใช้ requirePerm() ที่บังคับ ABAC อัตโนมัติจาก permissions.scope_level | ||||||
|  |  | ||||||
| /** | export function projectScopedViewFallback(moduleName) { | ||||||
|  * ดึง project_id ที่ผู้ใช้เข้าถึงได้ (จาก user_project_roles) |   // ใช้ในเคส legacy เท่านั้น | ||||||
|  */ |  | ||||||
| export async function getUserProjectIds(user_id) { |  | ||||||
|   const UPR = UPRModel(sequelize); |  | ||||||
|   const rows = await UPR.findAll({ where: { user_id } }); |  | ||||||
|   return [...new Set(rows.map(r => r.project_id))]; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * projectScopedView(moduleName) -> middleware |  | ||||||
|  * - ต้องมี permission '<module>:view' หรือ |  | ||||||
|  * - เป็นสมาชิกของโปรเจ็กต์ (ผ่าน user_project_roles) |  | ||||||
|  * Behavior: |  | ||||||
|  * - ถ้า query ไม่มี project_id และผู้ใช้ไม่ใช่ Admin: |  | ||||||
|  *     จำกัดผลลัพธ์ให้เฉพาะโปรเจ็กต์ที่ผู้ใช้เป็นสมาชิก |  | ||||||
|  * - ถ้ามี project_id: บังคับตรวจสิทธิ์การเป็นสมาชิกของโปรเจ็กต์นั้น (เว้นแต่เป็น Admin) |  | ||||||
|  */ |  | ||||||
| export function projectScopedView(moduleName) { |  | ||||||
|   return async (req, res, next) => { |   return async (req, res, next) => { | ||||||
|     const roles = req.user?.roles || []; |     const p = req.principal; | ||||||
|     const isAdmin = roles.includes('Admin'); |     if (!p) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|     const permName = `${moduleName}:view`; |  | ||||||
|     const hasViewPerm = (req.user?.permissions || []).includes(permName); |  | ||||||
|  |  | ||||||
|     // Admin ผ่านได้เสมอ |     const hasViewPerm = p.can?.(`${moduleName}.view`) || p.permissions?.has?.(`${moduleName}.view`); | ||||||
|     if (isAdmin) return next(); |     if (p.is_superadmin) return next(); | ||||||
|  |  | ||||||
|     const qProjectId = req.query?.project_id ? Number(req.query.project_id) : null; |     const qProjectId = req.query?.project_id ? Number(req.query.project_id) : null; | ||||||
|     const memberProjects = await getUserProjectIds(req.user?.user_id); |  | ||||||
|  |  | ||||||
|     if (qProjectId) { |     if (qProjectId) { | ||||||
|       // ต้องเป็นสมาชิกโปรเจ็กต์นั้น หรือมี perm view |       if (hasViewPerm || p.inProject(qProjectId)) return next(); | ||||||
|       if (hasViewPerm || memberProjects.includes(qProjectId)) return next(); |       return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|       return res.status(403).json({ error: 'Forbidden: not a member of project' }); |  | ||||||
|     } else { |     } else { | ||||||
|       // ไม่มี project_id: ถ้ามี perm view → อนุญาตทั้งหมด |  | ||||||
|       // ถ้าไม่มี perm view → จำกัดด้วยรายการโปรเจ็กต์ที่เป็นสมาชิก (บันทึกไว้ใน req.abac.filterProjectIds) |  | ||||||
|       if (hasViewPerm) return next(); |       if (hasViewPerm) return next(); | ||||||
|       if (!memberProjects.length) return res.status(403).json({ error: 'Forbidden: no accessible projects' }); |       if (!p.project_ids?.length) return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|       req.abac = req.abac || {}; |       req.abac = req.abac || {}; | ||||||
|       req.abac.filterProjectIds = memberProjects; |       req.abac.filterProjectIds = p.project_ids; | ||||||
|       return next(); |       return next(); | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * บังคับเป็นสมาชิกโปรเจ็กต์จากค่า project_id ใน body |  | ||||||
|  * ใช้กับ create endpoints |  | ||||||
|  */ |  | ||||||
| export function requireProjectMembershipFromBody() { |  | ||||||
|   return async (req, res, next) => { |  | ||||||
|     const roles = req.user?.roles || []; |  | ||||||
|     const isAdmin = roles.includes('Admin'); |  | ||||||
|     if (isAdmin) return next(); |  | ||||||
|     const pid = Number(req.body?.project_id); |  | ||||||
|     if (!pid) return res.status(400).json({ error: 'project_id required' }); |  | ||||||
|     const memberProjects = await getUserProjectIds(req.user?.user_id); |  | ||||||
|     if (!memberProjects.includes(pid)) return res.status(403).json({ error: 'Forbidden: not a project member' }); |  | ||||||
|     next(); |  | ||||||
|   }; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * บังคับเป็นสมาชิกโปรเจ็กต์โดยอ้างอิงจากเรคคอร์ด (ใช้กับ update/delete) |  | ||||||
|  * opts: { modelLoader: (sequelize)=>Model, idParam: 'id', projectField: 'project_id' } |  | ||||||
|  */ |  | ||||||
| export function requireProjectMembershipByRecord(opts) { |  | ||||||
|   const { modelLoader, idParam='id', projectField='project_id' } = opts; |  | ||||||
|   return async (req, res, next) => { |  | ||||||
|     const roles = req.user?.roles || []; |  | ||||||
|     const isAdmin = roles.includes('Admin'); |  | ||||||
|     if (isAdmin) return next(); |  | ||||||
|     const id = Number(req.params[idParam]); |  | ||||||
|     if (!id) return res.status(400).json({ error: 'Invalid id' }); |  | ||||||
|     const Model = modelLoader(sequelize); |  | ||||||
|     const row = await Model.findByPk(id); |  | ||||||
|     if (!row) return res.status(404).json({ error: 'Not found' }); |  | ||||||
|     const pid = Number(row[projectField]); |  | ||||||
|     const memberProjects = await getUserProjectIds(req.user?.user_id); |  | ||||||
|     if (!memberProjects.includes(pid)) return res.status(403).json({ error: 'Forbidden: not a project member' }); |  | ||||||
|     next(); |  | ||||||
|   }; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * บังคับให้ view ทุกอันต้องส่ง project_id (ยกเว้น Admin) |  | ||||||
|  */ |  | ||||||
| export function requireProjectIdQuery() { | export function requireProjectIdQuery() { | ||||||
|   return async (req, res, next) => { |   return (req, res, next) => { | ||||||
|     const roles = req.user?.roles || []; |     const p = req.principal; | ||||||
|     const isAdmin = roles.includes('Admin'); |     if (!p) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|     if (isAdmin) return next(); |     if (p.is_superadmin) return next(); | ||||||
|     const qProjectId = req.query?.project_id ? Number(req.query.project_id) : null; |     const qProjectId = req.query?.project_id ? Number(req.query.project_id) : null; | ||||||
|     if (!qProjectId) return res.status(400).json({ error: 'project_id query required' }); |     if (!qProjectId) return res.status(400).json({ error: "project_id query required" }); | ||||||
|     next(); |     next(); | ||||||
|   }; |   }; | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										61
									
								
								backend/src/middleware/auth copy.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										61
									
								
								backend/src/middleware/auth copy.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,61 @@ | |||||||
|  | // FILE: backend/src/middleware/auth.js | ||||||
|  |  | ||||||
|  | import jwt from "jsonwebtoken"; | ||||||
|  | import { config } from "../config.js"; | ||||||
|  | import { User, Role, UserRole } from "../db/sequelize.js"; | ||||||
|  |  | ||||||
|  | export function signAccessToken(payload) { | ||||||
|  |   return jwt.sign(payload, config.JWT.SECRET, { | ||||||
|  |     expiresIn: config.JWT.EXPIRES_IN, | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | export function signRefreshToken(payload) { | ||||||
|  |   return jwt.sign(payload, config.JWT.REFRESH_SECRET, { | ||||||
|  |     expiresIn: config.JWT.REFRESH_EXPIRES_IN, | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function extractToken(req) { | ||||||
|  |   // ให้คุกกี้มาก่อน แล้วค่อย Bearer (รองรับทั้งสองทาง) | ||||||
|  |   const cookieTok = req.cookies?.access_token || null; | ||||||
|  |   if (cookieTok) return cookieTok; | ||||||
|  |   const hdr = req.headers.authorization || ""; | ||||||
|  |   return hdr.startsWith("Bearer ") ? hdr.slice(7) : null; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function requireAuth(req, res, next) { | ||||||
|  |   if (req.path === "/health") return next(); // อนุญาต health เสมอ | ||||||
|  |   const token = extractToken(req); | ||||||
|  |   if (!token) return res.status(401).json({ error: "Missing token" }); | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     req.user = jwt.verify(token, config.JWT.SECRET); | ||||||
|  |     next(); | ||||||
|  |   } catch { | ||||||
|  |     return res.status(401).json({ error: "Invalid/Expired token" }); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | // ใช้กับเส้นทางที่ login แล้วจะ enrich ต่อได้ แต่ไม่บังคับ | ||||||
|  | export function optionalAuth(req, _res, next) { | ||||||
|  |   const token = extractToken(req); | ||||||
|  |   if (!token) return next(); | ||||||
|  |   try { | ||||||
|  |     req.user = jwt.verify(token, config.JWT.SECRET); | ||||||
|  |   } catch {} | ||||||
|  |   next(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export async function enrichRoles(req, _res, next) { | ||||||
|  |   if (!req.user?.user_id) return next(); | ||||||
|  |   const rows = await UserRole.findAll({ | ||||||
|  |     where: { user_id: req.user.user_id }, | ||||||
|  |     include: [{ model: Role }], | ||||||
|  |   }).catch(() => []); | ||||||
|  |   req.user.roles = rows.map((r) => r.role?.role_name).filter(Boolean); | ||||||
|  |   next(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function hasPerm(req, perm) { | ||||||
|  |   const set = new Set(req?.user?.permissions || []); | ||||||
|  |   return set.has(perm); | ||||||
|  | } | ||||||
							
								
								
									
										43
									
								
								backend/src/middleware/auth.js
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						
									
										43
									
								
								backend/src/middleware/auth.js
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							| @@ -1,37 +1,30 @@ | |||||||
| import jwt from 'jsonwebtoken'; | // FILE: backend/src/middleware/auth.js | ||||||
| import { config } from '../config.js'; | // (ถ้ายังใช้อยู่) ปรับให้สอดคล้อง Bearer + principal | ||||||
| import { User, Role, UserRole } from '../db/sequelize.js'; | import jwt from "jsonwebtoken"; | ||||||
|  |  | ||||||
| export function signAccessToken(payload) { | export function signAccessToken(payload) { | ||||||
|   return jwt.sign(payload, config.JWT.SECRET, { expiresIn: config.JWT.EXPIRES_IN }); |   const { JWT_SECRET = "dev-secret", JWT_EXPIRES_IN = "30m" } = process.env; | ||||||
|  |   return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, issuer: "dms-backend" }); | ||||||
| } | } | ||||||
| export function signRefreshToken(payload) { | export function signRefreshToken(payload) { | ||||||
|   return jwt.sign(payload, config.JWT.REFRESH_SECRET, { expiresIn: config.JWT.REFRESH_EXPIRES_IN }); |   const { JWT_REFRESH_SECRET = "dev-refresh", JWT_REFRESH_EXPIRES_IN = "30d" } = process.env; | ||||||
|  |   return jwt.sign({ ...payload, t: "refresh" }, JWT_REFRESH_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN, issuer: "dms-backend" }); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // ถ้าจะใช้ standalone (ไม่แนะนำถ้ามี authJwt แล้ว) | ||||||
| export function requireAuth(req, res, next) { | export function requireAuth(req, res, next) { | ||||||
|   if (req.path === '/health') return next();   // อนุญาต health เสมอ |   const h = req.headers.authorization || ""; | ||||||
|   const hdr = req.headers.authorization || ''; |   const m = /^Bearer\s+(.+)$/i.exec(h || ""); | ||||||
|   const token = hdr.startsWith('Bearer ') ? hdr.slice(7) : null; |   if (!m) return res.status(401).json({ error: "Missing token" }); | ||||||
|   if (!token) return res.status(401).json({ error: 'Missing token' }); |  | ||||||
|  |  | ||||||
|   try { |   try { | ||||||
|     req.user = jwt.verify(token, config.JWT.SECRET); |     const { JWT_SECRET = "dev-secret" } = process.env; | ||||||
|  |     const payload = jwt.verify(m[1], JWT_SECRET, { issuer: "dms-backend" }); | ||||||
|  |     req.auth = { user_id: payload.user_id, username: payload.username }; | ||||||
|  |     req.user = req.user || {}; | ||||||
|  |     req.user.user_id = payload.user_id; | ||||||
|  |     req.user.username = payload.username; | ||||||
|     next(); |     next(); | ||||||
|   } catch { |   } catch { | ||||||
|     return res.status(401).json({ error: 'Invalid/Expired token' }); |     return res.status(401).json({ error: "Invalid/Expired token" }); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| export async function enrichRoles(req, _res, next) { |  | ||||||
|   if (!req.user?.user_id) return next(); |  | ||||||
|   const rows = await UserRole.findAll({ where: { user_id: req.user.user_id }, include: [{ model: Role }] }) |  | ||||||
|     .catch(() => []); |  | ||||||
|   req.user.roles = rows.map(r => r.role?.role_name).filter(Boolean); |  | ||||||
|   next(); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export function hasPerm(req, perm) { |  | ||||||
|   const set = new Set(req?.user?.permissions || []); |  | ||||||
|   return set.has(perm); |  | ||||||
| } |  | ||||||
| @@ -1,18 +1,37 @@ | |||||||
|  | // FILE: src/middleware/authJwt.js | ||||||
|  | // 03.2 4) เพิ่ม middleware authJwt (ใหม่) | ||||||
|  | // นำ middleware นี้ไปใส่ หลัง verify JWT เสมอ เช่น app.use('/api', authJwt(), routes) | ||||||
|  | // Simple JWT authentication middleware example | ||||||
|  | // - For demonstration or simple use cases | ||||||
|  | // - Not as feature-rich as auth.js (no role/permission enrichment) | ||||||
|  | // - Can be used standalone or alongside auth.js | ||||||
| // authJwt.js – สมมติคุณมี JWT อยู่แล้ว (ปรับ verify ตามที่ใช้จริง) | // authJwt.js – สมมติคุณมี JWT อยู่แล้ว (ปรับ verify ตามที่ใช้จริง) | ||||||
| import jwt from 'jsonwebtoken'; | // - ตรวจ token และเติม req.user | ||||||
| const { JWT_SECRET = 'dev-secret' } = process.env; | // - ไม่ได้เติม roles/permissions (ถ้าต้องการให้ใช้ auth.js แทนหรือร่วมกัน) | ||||||
|  |  | ||||||
|  | import jwt from "jsonwebtoken"; | ||||||
|  |  | ||||||
| export function authJwt() { | export function authJwt() { | ||||||
|  |   const { JWT_SECRET = "dev-secret" } = process.env; | ||||||
|   return (req, res, next) => { |   return (req, res, next) => { | ||||||
|     const h = req.headers.authorization || ''; |     const h = req.headers.authorization || ""; | ||||||
|     const token = h.startsWith('Bearer ') ? h.slice(7) : null; |     // const token = h.startsWith("Bearer ") ? h.slice(7) : null; | ||||||
|     if (!token) return res.status(401).json({ error: 'Unauthenticated' }); |     const m = /^Bearer\s+(.+)$/i.exec(h || ""); | ||||||
|  |     //if (!token) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|  |     if (!m) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|     try { |     try { | ||||||
|       const payload = jwt.verify(token, JWT_SECRET); |       //const payload = jwt.verify(token, JWT_SECRET); | ||||||
|       req.user = { user_id: payload.user_id, username: payload.username }; |       const payload = jwt.verify(m[1], JWT_SECRET, { issuer: "dms-backend" }); | ||||||
|  |       // แนบข้อมูลขั้นต่ำให้ middleware ถัดไป | ||||||
|  |       req.auth = { user_id: payload.user_id, username: payload.username }; | ||||||
|  |       //req.user = { user_id: payload.user_id, username: payload.username }; | ||||||
|  |       // เผื่อโค้ดเก่าอ้างอิง req.user | ||||||
|  |       req.user = req.user || {}; | ||||||
|  |       req.user.user_id = payload.user_id; | ||||||
|  |       req.user.username = payload.username; | ||||||
|       next(); |       next(); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       return res.status(401).json({ error: 'Invalid token' }); |       return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,7 +1,13 @@ | |||||||
|  | // FILE: src/middleware/errorHandler.js | ||||||
|  | // Error handling middleware | ||||||
|  | // - 404 Not Found handler | ||||||
|  | // - General error handler | ||||||
|  | // - Should be the last middleware added | ||||||
|  |  | ||||||
| export function notFound(_req, res, _next) { | export function notFound(_req, res, _next) { | ||||||
|   res.status(404).json({ error: 'Not Found' }); |   res.status(404).json({ error: "Not Found" }); | ||||||
| } | } | ||||||
| export function errorHandler(err, _req, res, _next) { | export function errorHandler(err, _req, res, _next) { | ||||||
|   console.error(err); |   console.error(err); | ||||||
|   res.status(500).json({ error: 'Internal Server Error' }); |   res.status(500).json({ error: "Internal Server Error" }); | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										39
									
								
								backend/src/middleware/index.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										39
									
								
								backend/src/middleware/index.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,39 @@ | |||||||
|  | // File: backend/src/middleware/index.js | ||||||
|  | import * as authJwt from "./authJwt.js"; | ||||||
|  | import * as abac from "./abac.js"; | ||||||
|  | import * as auth from "./auth.js"; | ||||||
|  | import * as errorHandler from "./errorHandler.js"; | ||||||
|  | import * as loadPrincipal from "./loadPrincipal.js"; | ||||||
|  | import * as permGuard from "./permGuard.js"; | ||||||
|  | import * as permissions from "./permissions.js"; | ||||||
|  | import * as rbac from "./rbac.js"; | ||||||
|  | import * as requirePerm from "./requirePerm.js"; | ||||||
|  |  | ||||||
|  | // Export ทุกอย่างออกมาเป็น named exports | ||||||
|  | // เพื่อให้สามารถ import แบบ `import { authJwt, permGuard } from '../middleware';` ได้ | ||||||
|  | export { | ||||||
|  |   authJwt, | ||||||
|  |   abac, | ||||||
|  |   auth, | ||||||
|  |   errorHandler, | ||||||
|  |   loadPrincipal, | ||||||
|  |   permGuard, | ||||||
|  |   permissions, | ||||||
|  |   rbac, | ||||||
|  |   requirePerm, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // (Optional) สร้าง default export สำหรับกรณีที่ต้องการ import ทั้งหมดใน object เดียว | ||||||
|  | const middleware = { | ||||||
|  |   authJwt, | ||||||
|  |   abac, | ||||||
|  |   auth, | ||||||
|  |   errorHandler, | ||||||
|  |   loadPrincipal, | ||||||
|  |   permGuard, | ||||||
|  |   permissions, | ||||||
|  |   rbac, | ||||||
|  |   requirePerm, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default middleware; | ||||||
							
								
								
									
										95
									
								
								backend/src/middleware/loadPrincipal.js
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						
									
										95
									
								
								backend/src/middleware/loadPrincipal.js
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							| @@ -1,15 +1,98 @@ | |||||||
| // loadPrincipal.js | // FILE: src/middleware/loadPrincipal.js | ||||||
| import { loadPrincipal } from '../utils/rbac.js'; | // 03.2 4) เพิ่ม middleware loadPrincipal (ใหม่) | ||||||
|  | // นำ middleware นี้ไปใส่ หลัง verify JWT เสมอ เช่น app.use('/api', authJwt(), loadPrincipalMw(), routes) | ||||||
|  | // Load principal (roles + permissions) middleware | ||||||
|  | // - Uses rbac.js utility to load principal info | ||||||
|  | // - Attaches to req.principal | ||||||
|  | // - Requires req.user.user_id to be populated (e.g. via auth.js or authJwt.js) | ||||||
|  | // โหลด principal จาก DB แล้วแนบไว้ใน req.principal | ||||||
|  | // NOTE: ตรงนี้สมมุติว่าคุณมี service/query ฝั่ง DB อยู่แล้ว (เช่น sql/Sequelize) | ||||||
|  | //       ถ้าคุณมีฟังก์ชันโหลด principal อยู่ที่อื่น ให้แทน logic DB ตรง FIXME ด้านล่าง | ||||||
|  | // ใช้ req.auth.user_id และตั้ง req.principal ให้ครบ (RBAC + ABAC) | ||||||
|  |  | ||||||
|  | import sql from "../db/index.js"; | ||||||
|  |  | ||||||
| export function loadPrincipalMw() { | export function loadPrincipalMw() { | ||||||
|   return async (req, res, next) => { |   return async (req, res, next) => { | ||||||
|     try { |     try { | ||||||
|       if (!req.user?.user_id) return res.status(401).json({ error: 'Unauthenticated' }); |       const uid = req?.auth?.user_id || req?.user?.user_id; | ||||||
|       req.principal = await loadPrincipal(req.user.user_id); |       if (!uid) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|  |  | ||||||
|  |       // --- 1) users (รวม org_id) | ||||||
|  |       const [[u]] = await sql.query( | ||||||
|  |         `SELECT user_id, username, email, first_name, last_name, org_id, is_active | ||||||
|  |            FROM users WHERE user_id=? LIMIT 1`, | ||||||
|  |         [uid] | ||||||
|  |       ); | ||||||
|  |       if (!u || u.is_active === 0) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|  |  | ||||||
|  |       // --- 2) roles (global) | ||||||
|  |       const [roleRows] = await sql.query( | ||||||
|  |         `SELECT r.role_id, r.role_code, r.role_name | ||||||
|  |            FROM user_roles ur | ||||||
|  |            JOIN roles r ON r.role_id = ur.role_id | ||||||
|  |           WHERE ur.user_id=?`, | ||||||
|  |         [uid] | ||||||
|  |       ); | ||||||
|  |       const roleCodes = new Set(roleRows.map(r => r.role_code)); | ||||||
|  |       const is_superadmin = roleCodes.has("SUPER_ADMIN"); | ||||||
|  |  | ||||||
|  |       // --- 3) permissions (ผ่าน role_permissions) | ||||||
|  |       const [permRows] = await sql.query( | ||||||
|  |         `SELECT DISTINCT p.perm_code | ||||||
|  |            FROM user_roles ur | ||||||
|  |            JOIN role_permissions rp ON rp.role_id = ur.role_id | ||||||
|  |            JOIN permissions p       ON p.permission_id = rp.permission_id AND p.is_active=1 | ||||||
|  |           WHERE ur.user_id=?`, | ||||||
|  |         [uid] | ||||||
|  |       ); | ||||||
|  |       const permSet = new Set(permRows.map(x => x.perm_code)); | ||||||
|  |  | ||||||
|  |       // --- 4) project scope (user_project_roles) | ||||||
|  |       const [projRows] = await sql.query( | ||||||
|  |         `SELECT DISTINCT project_id FROM user_project_roles WHERE user_id=?`, | ||||||
|  |         [uid] | ||||||
|  |       ); | ||||||
|  |       const project_ids = projRows.map(r => r.project_id); | ||||||
|  |  | ||||||
|  |       // --- 5) org scope: users.org_id + orgs จาก project_parties ของโปรเจ็คที่เข้าถึง | ||||||
|  |       const baseOrgIds = u.org_id ? [u.org_id] : []; | ||||||
|  |       let projOrgIds = []; | ||||||
|  |       if (project_ids.length) { | ||||||
|  |         const [rows] = await sql.query( | ||||||
|  |           `SELECT DISTINCT org_id FROM project_parties WHERE project_id IN (?)`, | ||||||
|  |           [project_ids] | ||||||
|  |         ); | ||||||
|  |         projOrgIds = rows.map(r => r.org_id); | ||||||
|  |       } | ||||||
|  |       const org_ids = Array.from(new Set([...baseOrgIds, ...projOrgIds])); | ||||||
|  |  | ||||||
|  |       req.principal = { | ||||||
|  |         user_id: u.user_id, | ||||||
|  |         username: u.username, | ||||||
|  |         email: u.email, | ||||||
|  |         first_name: u.first_name, | ||||||
|  |         last_name: u.last_name, | ||||||
|  |         org_id: u.org_id || null, | ||||||
|  |  | ||||||
|  |         roles: roleRows.map(r => ({ role_id: r.role_id, role_code: r.role_code, role_name: r.role_name })), | ||||||
|  |         permissions: permSet,          // Set ของ perm_code | ||||||
|  |         project_ids, | ||||||
|  |         org_ids, | ||||||
|  |         is_superadmin, | ||||||
|  |  | ||||||
|  |         // helpers | ||||||
|  |         can: (code) => is_superadmin || permSet.has(code), | ||||||
|  |         canAny: (codes=[]) => is_superadmin || codes.some(c => permSet.has(c)), | ||||||
|  |         canAll: (codes=[]) => is_superadmin || codes.every(c => permSet.has(c)), | ||||||
|  |         inProject: (pid) => is_superadmin || project_ids.includes(Number(pid)), | ||||||
|  |         inOrg: (oid) => is_superadmin || org_ids.includes(Number(oid)), | ||||||
|  |       }; | ||||||
|  |  | ||||||
|       next(); |       next(); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       console.error('loadPrincipal error', err); |       console.error("loadPrincipal error", err); | ||||||
|       res.status(500).json({ error: 'Failed to load principal' }); |       res.status(500).json({ error: "Failed to load principal" }); | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,12 +1,15 @@ | |||||||
| /** | // FILE: src/middleware/permGuard.js | ||||||
|  * requirePerm('rfa:create') => ตรวจว่ามี permission นี้ใน req.user.permissions | // Permission guard middleware | ||||||
|  * ต้องแน่ใจว่าเรียก enrichPermissions() มาก่อน หรือคำนวณที่จุดเข้าใช้งาน | // - Checks if user has required permissions | ||||||
|  */ | // - Requires req.user.permissions to be populated (e.g. via auth.js or authJwt.js with enrichment) | ||||||
|  | // เปลี่ยนให้เป็น wrapper ที่เรียก req.principal (ทางเก่ายังใช้ได้)** | ||||||
|  |  | ||||||
| export function requirePerm(...allowedPerms) { | export function requirePerm(...allowedPerms) { | ||||||
|   return (req, res, next) => { |   return (req, res, next) => { | ||||||
|     const perms = req.user?.permissions || []; |     const p = req.principal; | ||||||
|     const ok = perms.some(p => allowedPerms.includes(p)); |     if (!p) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|     if (!ok) return res.status(403).json({ error: 'Forbidden' }); |     const ok = p.is_superadmin || allowedPerms.some((code) => p.permissions?.has?.(code)); | ||||||
|  |     if (!ok) return res.status(403).json({ error: "FORBIDDEN", need_any_of: allowedPerms }); | ||||||
|     next(); |     next(); | ||||||
|   }; |   }; | ||||||
| } | } | ||||||
| @@ -1,34 +1,40 @@ | |||||||
| import { Role, Permission, UserRole, RolePermission } from '../db/sequelize.js'; | // FILE: src/middleware/permissions.js | ||||||
|  | // Permission calculation and enrichment middleware | ||||||
|  | // - Computes effective permissions for a user based on their roles | ||||||
|  | // - Attaches permissions to req.user.permissions | ||||||
|  | // ใช้เฉพาะกรณีที่คุณยังมี stack Sequelize เดิมอยู่ และอยาก enrich จาก Role/Permission model | ||||||
|  | // โดยทั่วไป ถ้าคุณใช้ loadPrincipalMw() อยู่แล้ว สามารถไม่ใช้ไฟล์นี้ได้ | ||||||
|  |  | ||||||
|  | import { Permission, UserRole, RolePermission } from "../db/sequelize.js"; | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * คืนชุด permission (string[]) ของ user_id |  | ||||||
|  */ |  | ||||||
| export async function computeEffectivePermissions(user_id) { | export async function computeEffectivePermissions(user_id) { | ||||||
|   // ดึง roles ของผู้ใช้ |  | ||||||
|   const userRoles = await UserRole.findAll({ where: { user_id } }); |   const userRoles = await UserRole.findAll({ where: { user_id } }); | ||||||
|   const roleIds = userRoles.map(r => r.role_id); |   const roleIds = userRoles.map((r) => r.role_id); | ||||||
|   if (!roleIds.length) return []; |   if (!roleIds.length) return []; | ||||||
|  |  | ||||||
|   // ดึง permission ผ่าน role_permissions |  | ||||||
|   const rp = await RolePermission.findAll({ where: { role_id: roleIds } }); |   const rp = await RolePermission.findAll({ where: { role_id: roleIds } }); | ||||||
|   const permIds = [...new Set(rp.map(x => x.permission_id))]; |   const permIds = [...new Set(rp.map((x) => x.permission_id))]; | ||||||
|   if (!permIds.length) return []; |   if (!permIds.length) return []; | ||||||
|  |  | ||||||
|   const perms = await Permission.findAll({ where: { permission_id: permIds } }); |   const perms = await Permission.findAll({ where: { permission_id: permIds } }); | ||||||
|   return [...new Set(perms.map(p => p.permission_name))]; |   // ใช้ perm_code ให้สอดคล้อง seed | ||||||
|  |   return [...new Set(perms.map((p) => p.perm_code))]; | ||||||
| } | } | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * middleware: เติม permissions ลง req.user.permissions |  | ||||||
|  */ |  | ||||||
| export function enrichPermissions() { | export function enrichPermissions() { | ||||||
|   return async (req, _res, next) => { |   return async (req, _res, next) => { | ||||||
|     if (!req.user?.user_id) return next(); |     const uid = req?.auth?.user_id || req?.user?.user_id; | ||||||
|  |     if (!uid) return next(); | ||||||
|     try { |     try { | ||||||
|       const perms = await computeEffectivePermissions(req.user.user_id); |       const perms = await computeEffectivePermissions(uid); | ||||||
|  |       // อัปเดตทั้ง req.principal และ req.user (เผื่อโค้ดเก่า) | ||||||
|  |       req.principal = req.principal || {}; | ||||||
|  |       req.principal.permissions = new Set(perms); | ||||||
|  |       req.user = req.user || {}; | ||||||
|       req.user.permissions = perms; |       req.user.permissions = perms; | ||||||
|     } catch (e) { |     } catch { | ||||||
|       req.user.permissions = []; |       if (req.principal) req.principal.permissions = new Set(); | ||||||
|  |       if (req.user) req.user.permissions = []; | ||||||
|     } |     } | ||||||
|     next(); |     next(); | ||||||
|   }; |   }; | ||||||
|   | |||||||
| @@ -1,17 +1,23 @@ | |||||||
|  | // FILE: src/middleware/rbac.js | ||||||
|  | // RBAC: Role-Based Access Control middleware helpers | ||||||
|  | // - Role and Permission guard middleware | ||||||
|  | // - Requires req.user.roles and req.user.permissions to be populated (e.g. via auth.js with enrichment) | ||||||
|  |  | ||||||
| export function requireRole(...allowed) { | export function requireRole(...allowed) { | ||||||
|   return (req, res, next) => { |   return (req, res, next) => { | ||||||
|     const roles = req.user?.roles || []; |     const roles = (req.principal?.roles || []).map(r => r.role_code); | ||||||
|     const ok = roles.some(r => allowed.includes(r)); |     const ok = roles.some((r) => allowed.includes(r)) || req.principal?.is_superadmin; | ||||||
|     if (!ok) return res.status(403).json({ error: 'Forbidden' }); |     if (!ok) return res.status(403).json({ error: "FORBIDDEN_ROLE", need_any_of: allowed }); | ||||||
|     next(); |     next(); | ||||||
|   }; |   }; | ||||||
| } | } | ||||||
|  |  | ||||||
| export function requirePermission(...allowedPerms) { | export function requirePermissionCode(...codes) { | ||||||
|   return (req, res, next) => { |   return (req, res, next) => { | ||||||
|     const perms = req.user?.permissions || []; |     const p = req.principal; | ||||||
|     const ok = perms.some(p => allowedPerms.includes(p)); |     if (!p) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|     if (!ok) return res.status(403).json({ error: 'Forbidden' }); |     const ok = p.is_superadmin || codes.some((c) => p.permissions?.has?.(c)); | ||||||
|  |     if (!ok) return res.status(403).json({ error: "FORBIDDEN", need_any_of: codes }); | ||||||
|     next(); |     next(); | ||||||
|   }; |   }; | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								backend/src/middleware/requireBearer.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										18
									
								
								backend/src/middleware/requireBearer.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | // FILE: src/middleware/requireBearer.js | ||||||
|  | import jwt from "jsonwebtoken"; | ||||||
|  | import { findUserById } from "../db/models/users.js"; | ||||||
|  |  | ||||||
|  | export async function requireBearer(req, res, next) { | ||||||
|  |   const hdr = req.get("Authorization") || ""; | ||||||
|  |   const m = hdr.match(/^Bearer\s+(.+)$/i); | ||||||
|  |   if (!m) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|  |   try { | ||||||
|  |     const payload = jwt.verify(m[1], process.env.JWT_ACCESS_SECRET, { issuer: "dms-backend" }); | ||||||
|  |     const user = await findUserById(payload.user_id); | ||||||
|  |     if (!user) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|  |     req.user = { user_id: user.user_id, username: user.username, email: user.email, first_name: user.first_name, last_name: user.last_name }; | ||||||
|  |     next(); | ||||||
|  |   } catch { | ||||||
|  |     return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,25 +1,64 @@ | |||||||
| // src/middleware/requirePerm.js | // FILE: src/middleware/requirePerm.js | ||||||
| import { canPerform } from '../utils/rbac.js'; | // 03.2 4) เพิ่ม middleware requirePerm (ใหม่) | ||||||
|  | // นำ middleware นี้ไปใส่ หลัง loadPrincipal เสมอ เช่น app.use('/api', authJwt(), loadPrincipalMw(), requirePerm('correspondence.create', {scope:'org', getOrgId: req=>...}), routes) | ||||||
|  | // หรือใส่ใน route เดี่ยวๆ ก็ได้ เช่น router.post('/', requirePerm('correspondence.create', {scope:'org', getOrgId: req=>...}), (req,res)=>{...}) | ||||||
|  | // Permission requirement middleware with scope support | ||||||
|  | // - Uses canPerform() utility from rbac.js | ||||||
|  | // - Supports global, org, and project scopes | ||||||
|  | // - Requires req.principal to be populated (e.g. via loadPrincipal middleware) | ||||||
|  | // เช็คตาม perm_code + ABAC อัตโนมัติจาก permissions.scope_level | ||||||
|  | import sql from "../db/index.js"; | ||||||
|  |  | ||||||
|  | let _permMap = null; | ||||||
|  | let _loadedAt = 0; | ||||||
|  | const TTL_MS = 60_000; | ||||||
|  |  | ||||||
|  | async function getPermRegistry() { | ||||||
|  |   const now = Date.now(); | ||||||
|  |   if (_permMap && now - _loadedAt < TTL_MS) return _permMap; | ||||||
|  |   const [rows] = await sql.query( | ||||||
|  |     `SELECT perm_code, scope_level FROM permissions WHERE is_active=1` | ||||||
|  |   ); | ||||||
|  |   _permMap = new Map(rows.map(r => [r.perm_code, r.scope_level])); // GLOBAL | ORG | PROJECT | ||||||
|  |   _loadedAt = now; | ||||||
|  |   return _permMap; | ||||||
|  | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * requirePerm('correspondence.create', { scope: 'org', getOrgId: req => ... }) |  * requirePerm('rfas.view', { projectParam: 'project_id', orgParam: 'org_id' }) | ||||||
|  * scope: 'global' | 'org' | 'project' |  * - GLOBAL: แค่มี perm ก็ผ่าน | ||||||
|  |  * - ORG:    ต้องมี perm + อยู่ใน org scope (อ่าน org_id จาก param หากระบุ; ไม่ระบุจะใช้ req.principal.org_id) | ||||||
|  |  * - PROJECT:ต้องมี perm + อยู่ใน project scope (อ่าน project_id จาก param) | ||||||
|  */ |  */ | ||||||
| export function requirePerm(permCode, { scope = 'global', getOrgId = null, getProjectId = null } = {}) { | export function requirePerm(permCode, { projectParam, orgParam } = {}) { | ||||||
|   return async (req, res, next) => { |   return async (req, res, next) => { | ||||||
|     try { |     const p = req.principal; | ||||||
|       const orgId = getOrgId ? await getOrgId(req) : null; |     if (!p) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|       const projectId = getProjectId ? await getProjectId(req) : null; |  | ||||||
|  |  | ||||||
|       if (canPerform(req.principal, permCode, { scope, orgId, projectId })) return next(); |     if (!(p.is_superadmin || p.permissions?.has?.(permCode))) { | ||||||
|  |       return res.status(403).json({ error: "FORBIDDEN", need: permCode }); | ||||||
|       return res.status(403).json({ |  | ||||||
|         error: 'FORBIDDEN', |  | ||||||
|         message: `Require ${permCode} (${scope}-scoped)`, |  | ||||||
|       }); |  | ||||||
|     } catch (e) { |  | ||||||
|       console.error('requirePerm error', e); |  | ||||||
|       res.status(500).json({ error: 'Permission check error' }); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     const registry = await getPermRegistry(); | ||||||
|  |     const scope = registry.get(permCode) || "GLOBAL"; | ||||||
|  |  | ||||||
|  |     const readParam = (name) => req.params?.[name] ?? req.query?.[name] ?? req.body?.[name]; | ||||||
|  |  | ||||||
|  |     if (scope === "PROJECT") { | ||||||
|  |       const pid = Number(projectParam ? readParam(projectParam) : undefined); | ||||||
|  |       if (!p.is_superadmin) { | ||||||
|  |         if (!pid || !p.inProject(pid)) { | ||||||
|  |           return res.status(403).json({ error: "FORBIDDEN_PROJECT", project_id: pid || null }); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } else if (scope === "ORG") { | ||||||
|  |       const oid = Number(orgParam ? readParam(orgParam) : p.org_id); | ||||||
|  |       if (!p.is_superadmin) { | ||||||
|  |         if (!oid || !p.inOrg(oid)) { | ||||||
|  |           return res.status(403).json({ error: "FORBIDDEN_ORG", org_id: oid || null }); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     next(); | ||||||
|   }; |   }; | ||||||
| } | } | ||||||
| @@ -1,27 +1,23 @@ | |||||||
| // src/routes/admin.js | // FILE: src/routes/admin.js | ||||||
| import { Router } from 'express'; | import { Router } from "express"; | ||||||
| import sequelize from '../db/index.js'; | import os from "node:os"; | ||||||
| import { requireAuth } from '../middleware/auth.js'; | import sql from "../db/index.js"; | ||||||
| import { requirePermission } from '../middleware/perm.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
|  |  | ||||||
| const router = Router(); |  | ||||||
| // src/routes/admin.js |  | ||||||
| import { Router } from 'express'; |  | ||||||
| import os from 'node:os'; |  | ||||||
| import sql from '../db/index.js'; |  | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; |  | ||||||
| import PERM from '../config/permissions.js'; |  | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
|  |  | ||||||
| // GET /api/admin/sysinfo  → ต้องมี admin.read | /** | ||||||
| r.get('/sysinfo', |  * GET /api/admin/sysinfo | ||||||
|   requirePerm(PERM.admin.read, { scope: 'global' }), |  * perm: admin.access (ORG scope) – ใช้สิทธิ์กลุ่ม admin | ||||||
|   async (req, res) => { |  */ | ||||||
|  | r.get( | ||||||
|  |   "/sysinfo", | ||||||
|  |   requirePerm("admin.access", { orgParam: "org_id" }), | ||||||
|  |   async (_req, res) => { | ||||||
|     try { |     try { | ||||||
|       const [[{ now }]] = await sql.query('SELECT NOW() AS now'); |       await sql.query("SELECT 1"); | ||||||
|       res.json({ |       res.json({ | ||||||
|         now, |         now: new Date().toISOString(), | ||||||
|         node: process.version, |         node: process.version, | ||||||
|         platform: os.platform(), |         platform: os.platform(), | ||||||
|         arch: os.arch(), |         arch: os.arch(), | ||||||
| @@ -29,80 +25,70 @@ r.get('/sysinfo', | |||||||
|         uptime_sec: os.uptime(), |         uptime_sec: os.uptime(), | ||||||
|         loadavg: os.loadavg(), |         loadavg: os.loadavg(), | ||||||
|         memory: { total: os.totalmem(), free: os.freemem() }, |         memory: { total: os.totalmem(), free: os.freemem() }, | ||||||
|         env: { NODE_ENV: process.env.NODE_ENV, APP_VERSION: process.env.APP_VERSION }, |         env: { | ||||||
|  |           NODE_ENV: process.env.NODE_ENV, | ||||||
|  |           APP_VERSION: process.env.APP_VERSION, | ||||||
|  |         }, | ||||||
|       }); |       }); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       res.status(500).json({ error: 'SYSINFO_FAIL', message: e?.message }); |       res.status(500).json({ error: "SYSINFO_FAIL", message: e?.message }); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| // POST /api/admin/maintenance/reindex  → ต้องมี admin.maintain | /** | ||||||
| r.post('/maintenance/reindex', |  * POST /api/admin/maintenance/reindex | ||||||
|   requirePerm(PERM.admin.maintain, { scope: 'global' }), |  * perm: settings.manage (GLOBAL) – งานดูแลระบบ | ||||||
|  |  */ | ||||||
|  | r.post( | ||||||
|  |   "/maintenance/reindex", | ||||||
|  |   requirePerm("settings.manage"), | ||||||
|   async (_req, res) => { |   async (_req, res) => { | ||||||
|     // ตัวอย่าง: ANALYZE/OPTIMIZE ตารางสำคัญ (ปรับตามจริง) |  | ||||||
|     try { |     try { | ||||||
|       await sql.query('ANALYZE TABLE correspondences, rfas, drawings'); |       // ปรับตามตารางจริงของคุณ | ||||||
|  |       await sql.query("ANALYZE TABLE correspondences, rfas, drawings"); | ||||||
|       res.json({ ok: 1 }); |       res.json({ ok: 1 }); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       res.status(500).json({ error: 'MAINT_FAIL', message: e?.message }); |       res.status(500).json({ error: "MAINT_FAIL", message: e?.message }); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * GET /api/admin/perm-matrix?format=json | ||||||
|  |  * perm: admin.access (ORG) | ||||||
|  |  */ | ||||||
|  | r.get( | ||||||
|  |   "/perm-matrix", | ||||||
|  |   requirePerm("admin.access", { orgParam: "org_id" }), | ||||||
|  |   async (req, res) => { | ||||||
|  |     const format = String(req.query.format || "json").toLowerCase(); | ||||||
|  |     const [roles] = await sql.query( | ||||||
|  |       `SELECT r.role_id, r.role_code, r.role_name, | ||||||
|  |             GROUP_CONCAT(p.perm_code ORDER BY p.perm_code SEPARATOR ', ') AS perm_codes | ||||||
|  |        FROM roles r | ||||||
|  |        LEFT JOIN role_permissions rp ON rp.role_id = r.role_id | ||||||
|  |        LEFT JOIN permissions p ON p.permission_id = rp.permission_id | ||||||
|  |       GROUP BY r.role_id, r.role_code, r.role_name | ||||||
|  |       ORDER BY r.role_code` | ||||||
|  |     ); | ||||||
|  |     if (format === "json") return res.json({ roles }); | ||||||
|  |     // markdown แบบง่าย | ||||||
|  |     const lines = [ | ||||||
|  |       `# Permission Matrix`, | ||||||
|  |       `_Generated at: ${new Date().toISOString()}_`, | ||||||
|  |       `| # | Role Code | Role Name | Permissions |`, | ||||||
|  |       `|---:|:---------|:----------|:------------|`, | ||||||
|  |       ...roles.map( | ||||||
|  |         (r, i) => | ||||||
|  |           `| ${i + 1} | \`${r.role_code}\` | ${r.role_name || ""} | ${ | ||||||
|  |             r.perm_codes || "" | ||||||
|  |           } |` | ||||||
|  |       ), | ||||||
|  |     ]; | ||||||
|  |     res.setHeader("Content-Type", "text/markdown; charset=utf-8"); | ||||||
|  |     res.send(lines.join("\n")); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * GET /api/admin/perm-matrix |  | ||||||
|  * query: |  | ||||||
|  *   format=md|json  (default: md) |  | ||||||
|  * |  | ||||||
|  * ต้องมีสิทธิ์ ADMIN หรืออย่างน้อย CDWG_ADMIN/ALL (เปลี่ยนเป็นอะไรก็ได้ตามนโยบายคุณ) |  | ||||||
|  */ |  | ||||||
| router.get('/perm-matrix', |  | ||||||
|   requireAuth, |  | ||||||
|   // ใช้ ANY จากชุดสิทธิ์ด้านล่าง (คุณปรับให้เป็น ['ALL'] อย่างเดียวก็ได้) |  | ||||||
|   requirePermission(['ALL', 'CDWG_ADMIN'], { mode: 'any' }), |  | ||||||
|   async (req, res, next) => { |  | ||||||
|     try { |  | ||||||
|       const format = (req.query.format || 'md').toLowerCase(); |  | ||||||
|  |  | ||||||
|       // ดึง Role → Permissions (global) |  | ||||||
|       const [rows] = await sequelize.query(` |  | ||||||
|         SELECT |  | ||||||
|           r.role_id, |  | ||||||
|           r.role_code, |  | ||||||
|           r.role_name, |  | ||||||
|           GROUP_CONCAT(DISTINCT p.perm_code ORDER BY p.perm_code SEPARATOR ', ') AS perm_codes |  | ||||||
|         FROM roles r |  | ||||||
|         LEFT JOIN role_permissions rp ON rp.role_id = r.role_id |  | ||||||
|         LEFT JOIN permissions p       ON p.perm_id = rp.perm_id |  | ||||||
|         GROUP BY r.role_id, r.role_code, r.role_name |  | ||||||
|         ORDER BY r.role_code |  | ||||||
|       `); |  | ||||||
|  |  | ||||||
|       if (format === 'json') { |  | ||||||
|         return res.json({ roles: rows }); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       // สร้าง Markdown |  | ||||||
|       const lines = []; |  | ||||||
|       lines.push(`# Permission Matrix (Role → Permissions)`); |  | ||||||
|       lines.push(`_Generated at: ${new Date().toISOString()}_\n`); |  | ||||||
|       lines.push(`| # | Role Code | Role Name | Permissions |`); |  | ||||||
|       lines.push(`|---:|:---------|:----------|:------------|`); |  | ||||||
|       rows.forEach((r, idx) => { |  | ||||||
|         lines.push(`| ${idx + 1} | \`${r.role_code}\` | ${r.role_name || ''} | ${r.perm_codes || ''} |`); |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       const md = lines.join('\n'); |  | ||||||
|       res.setHeader('Content-Type', 'text/markdown; charset=utf-8'); |  | ||||||
|       return res.send(md); |  | ||||||
|     } catch (e) { |  | ||||||
|       next(e); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| export default router; |  | ||||||
|   | |||||||
							
								
								
									
										137
									
								
								backend/src/routes/auth พัง.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								backend/src/routes/auth พัง.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,137 @@ | |||||||
|  | // backend/src/routes/auth.js | ||||||
|  | import { Router } from "express"; | ||||||
|  | import bcrypt from "bcryptjs"; | ||||||
|  | import jwt from "jsonwebtoken"; | ||||||
|  | import { config } from "../config.js"; | ||||||
|  | import { User } from "../db/sequelize.js"; | ||||||
|  |  | ||||||
|  | import { | ||||||
|  |   signAccessToken, | ||||||
|  |   signRefreshToken, | ||||||
|  |   requireAuth, | ||||||
|  | } from "../middleware/auth.js"; | ||||||
|  |  | ||||||
|  | const r = Router(); | ||||||
|  |  | ||||||
|  | // cookie options — ทำในไฟล์นี้เลย (ไม่เพิ่ม utils ใหม่) | ||||||
|  | function cookieOpts(maxAgeMs) { | ||||||
|  |   const isProd = process.env.NODE_ENV === "production"; | ||||||
|  |   const opts = { | ||||||
|  |     httpOnly: true, | ||||||
|  |     secure: true, // หลัง Nginx/HTTPS | ||||||
|  |     sameSite: "none", // ส่งข้าม subdomain ได้ | ||||||
|  |     path: "/", | ||||||
|  |     maxAge: maxAgeMs, | ||||||
|  |   }; | ||||||
|  |   if (config.COOKIE_DOMAIN) opts.domain = config.COOKIE_DOMAIN; // เช่น .np-dms.work | ||||||
|  |   if (!isProd && process.env.ALLOW_INSECURE_COOKIE === "1") { | ||||||
|  |     opts.secure = false; | ||||||
|  |     opts.sameSite = "lax"; | ||||||
|  |   } | ||||||
|  |   return opts; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // helper TTL จาก config เดิม | ||||||
|  | const ACCESS_TTL_MS = (() => { | ||||||
|  |   // รับทั้งรูปแบบ "15m" (เช่น EXPIRES_IN) หรือ milliseconds | ||||||
|  |   // ถ้าเป็นเลขอยู่แล้ว (ms) ก็ใช้เลย | ||||||
|  |   if (/^\d+$/.test(String(config.JWT.EXPIRES_IN))) | ||||||
|  |     return Number(config.JWT.EXPIRES_IN); | ||||||
|  |   // แปลงรูปแบบเช่น "15m" เป็น ms แบบง่าย ๆ | ||||||
|  |   const s = String(config.JWT.EXPIRES_IN || "15m"); | ||||||
|  |   const n = parseInt(s, 10); | ||||||
|  |   if (s.endsWith("h")) return n * 60 * 60 * 1000; | ||||||
|  |   if (s.endsWith("m")) return n * 60 * 1000; | ||||||
|  |   if (s.endsWith("s")) return n * 1000; | ||||||
|  |   return 15 * 60 * 1000; | ||||||
|  | })(); | ||||||
|  | const REFRESH_TTL_MS = (() => { | ||||||
|  |   if (/^\d+$/.test(String(config.JWT.REFRESH_EXPIRES_IN))) | ||||||
|  |     return Number(config.JWT.REFRESH_EXPIRES_IN); | ||||||
|  |   const s = String(config.JWT.REFRESH_EXPIRES_IN || "7d"); | ||||||
|  |   const n = parseInt(s, 10); | ||||||
|  |   if (s.endsWith("d")) return n * 24 * 60 * 60 * 1000; | ||||||
|  |   if (s.endsWith("h")) return n * 60 * 60 * 1000; | ||||||
|  |   if (s.endsWith("m")) return n * 60 * 1000; | ||||||
|  |   if (s.endsWith("s")) return n * 1000; | ||||||
|  |   return 7 * 24 * 60 * 60 * 1000; | ||||||
|  | })(); | ||||||
|  |  | ||||||
|  | // == POST /api/auth/login == | ||||||
|  | r.post("/login", async (req, res) => { | ||||||
|  |   const { username, password } = req.body || {}; | ||||||
|  |   if (!username || !password) | ||||||
|  |     return res.status(400).json({ error: "USERNAME_PASSWORD_REQUIRED" }); | ||||||
|  |  | ||||||
|  |   const user = await User.findOne({ where: { username }, raw: true }); | ||||||
|  |   if (!user) return res.status(401).json({ error: "INVALID_CREDENTIALS" }); | ||||||
|  |  | ||||||
|  |   const ok = await bcrypt.compare(password, user.password_hash || ""); | ||||||
|  |   if (!ok) return res.status(401).json({ error: "INVALID_CREDENTIALS" }); | ||||||
|  |  | ||||||
|  |   // NOTE: สิทธิ์จริง ๆ ให้ดึงจากตาราง role/permission ของคุณ | ||||||
|  |   const permissions = []; // ใส่เปล่าไว้ก่อน (คุณมี enrichRoles ที่อื่นอยู่แล้ว) | ||||||
|  |   const payload = { | ||||||
|  |     user_id: user.user_id, | ||||||
|  |     username: user.username, | ||||||
|  |     permissions, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const access = signAccessToken(payload); | ||||||
|  |   const refresh = signRefreshToken({ user_id: user.user_id }); | ||||||
|  |  | ||||||
|  |   // ตั้งคุกกี้ (และยังส่ง token ใน body ได้ถ้าคุณใช้อยู่) | ||||||
|  |   res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS)); | ||||||
|  |   res.cookie("refresh_token", refresh, cookieOpts(REFRESH_TTL_MS)); | ||||||
|  |  | ||||||
|  |   return res.json({ | ||||||
|  |     ok: true, | ||||||
|  |     token: access, | ||||||
|  |     refresh_token: refresh, | ||||||
|  |     user: { user_id: user.user_id, username: user.username, email: user.email }, | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // == GET /api/auth/me == | ||||||
|  | r.get("/me", requireAuth, async (req, res) => { | ||||||
|  |   // enrich เพิ่มจากฐานได้ตามต้องการ; ตอนนี้เอาเบื้องต้นจาก token | ||||||
|  |   return res.json({ | ||||||
|  |     ok: true, | ||||||
|  |     user: { | ||||||
|  |       user_id: req.user.user_id, | ||||||
|  |       username: req.user.username, | ||||||
|  |       permissions: req.user.permissions || [], | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // == POST /api/auth/refresh == | ||||||
|  | r.post("/refresh", async (req, res) => { | ||||||
|  |   // รับจากคุกกี้ก่อน แล้วค่อย Authorization | ||||||
|  |   const bearer = req.headers.authorization?.startsWith("Bearer ") | ||||||
|  |     ? req.headers.authorization.slice(7) | ||||||
|  |     : null; | ||||||
|  |   const rt = req.cookies?.refresh_token || bearer; | ||||||
|  |   if (!rt) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     // verify refresh โดยตรงด้วย config.JWT.REFRESH_SECRET (คงสไตล์เดิม) | ||||||
|  |     const p = jwt.verify(rt, config.JWT.REFRESH_SECRET, { clockTolerance: 10 }); | ||||||
|  |     // โหลดสิทธิ์ล่าสุดจากฐานได้ ถ้าต้องการ; ตอนนี้ใส่ [] ไว้ก่อน | ||||||
|  |     const permissions = []; | ||||||
|  |     const access = signAccessToken({ user_id: p.user_id, permissions }); | ||||||
|  |     res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS)); | ||||||
|  |     return res.json({ ok: true, token: access }); | ||||||
|  |   } catch { | ||||||
|  |     return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // == POST /api/auth/logout == | ||||||
|  | r.post("/logout", (_req, res) => { | ||||||
|  |   res.clearCookie("access_token", { path: "/" }); | ||||||
|  |   res.clearCookie("refresh_token", { path: "/" }); | ||||||
|  |   return res.json({ ok: true }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | export default r; | ||||||
| @@ -1,57 +1,100 @@ | |||||||
| // src/routes/auth.js (ESM) | // FILE: backend/src/routes/auth.js (ESM) — Bearer only, refresh via header/body, forgot/reset password | ||||||
| import { Router } from 'express'; | import { Router } from "express"; | ||||||
| import jwt from 'jsonwebtoken'; | import jwt from "jsonwebtoken"; | ||||||
| import bcrypt from 'bcryptjs'; | import sql from "../db/index.js"; | ||||||
| import sql from '../db/index.js'; | import { cookieOpts } from "../utils/cookie.js"; | ||||||
|  | import bcrypt from "bcryptjs"; | ||||||
|  | import { requireAuth } from "../middleware/auth.js"; | ||||||
|  | import crypto from "node:crypto"; | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
|  |  | ||||||
| const JWT_SECRET = process.env.JWT_SECRET || 'dev-access-secret'; | /* ========================= | ||||||
| const REFRESH_SECRET = process.env.REFRESH_SECRET || 'dev-refresh-secret'; |  * CONFIG & HELPERS | ||||||
| const ACCESS_TTL = process.env.ACCESS_TTL || '30m';   // 30 นาที |  * ========================= */ | ||||||
| const REFRESH_TTL = process.env.REFRESH_TTL || '30d'; // 30 วัน | // ใช้ค่าเดียวกับ middleware authJwt() | ||||||
|  | const JWT_SECRET = process.env.JWT_SECRET || "dev-secret"; | ||||||
|  | const REFRESH_SECRET = process.env.REFRESH_SECRET || "dev-refresh-secret"; | ||||||
|  | const ACCESS_TTL = process.env.ACCESS_TTL || "30m"; | ||||||
|  | const REFRESH_TTL = process.env.REFRESH_TTL || "30d"; | ||||||
|  | // อายุของ reset token (นาที) | ||||||
|  | const RESET_TTL_MIN = Number(process.env.RESET_TTL_MIN || 30); | ||||||
|  |  | ||||||
| function signAccessToken(user) { | function signAccessToken(user) { | ||||||
|   return jwt.sign( |   return jwt.sign( | ||||||
|     { user_id: user.user_id, username: user.username }, |     { user_id: user.user_id, username: user.username }, | ||||||
|     JWT_SECRET, |     JWT_SECRET, | ||||||
|     { expiresIn: ACCESS_TTL, issuer: 'dms-backend' } |     { expiresIn: ACCESS_TTL, issuer: "dms-backend" } | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
| function signRefreshToken(user) { | function signRefreshToken(user) { | ||||||
|   return jwt.sign( |   return jwt.sign( | ||||||
|     { user_id: user.user_id, username: user.username, t: 'refresh' }, |     { user_id: user.user_id, username: user.username, t: "refresh" }, | ||||||
|     REFRESH_SECRET, |     REFRESH_SECRET, | ||||||
|     { expiresIn: REFRESH_TTL, issuer: 'dms-backend' } |     { expiresIn: REFRESH_TTL, issuer: "dms-backend" } | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | function getBearer(req) { | ||||||
|  |   const h = req.headers.authorization || ""; | ||||||
|  |   if (!h.startsWith("Bearer ")) return null; | ||||||
|  |   const token = h.slice(7).trim(); | ||||||
|  |   return token || null; | ||||||
|  | } | ||||||
|  |  | ||||||
| async function findUserByUsername(username) { | async function findUserByUsername(username) { | ||||||
|   const [[u]] = await sql.query( |   const [rows] = await sql.query( | ||||||
|     'SELECT user_id, username, password_hash, email, first_name, last_name FROM users WHERE username=?', |     `SELECT user_id, username, email, first_name, last_name, password_hash | ||||||
|  |      FROM users WHERE username=? LIMIT 1`, | ||||||
|     [username] |     [username] | ||||||
|   ); |   ); | ||||||
|   return u || null; |   return rows?.[0] || null; | ||||||
| } | } | ||||||
|  |  | ||||||
| // POST /api/auth/login | async function findUserByEmail(email) { | ||||||
| r.post('/login', async (req, res) => { |   const [rows] = await sql.query( | ||||||
|  |     `SELECT user_id, username, email, first_name, last_name, password_hash | ||||||
|  |      FROM users WHERE email=? LIMIT 1`, | ||||||
|  |     [email] | ||||||
|  |   ); | ||||||
|  |   return rows?.[0] || null; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* ========================= | ||||||
|  |  * POST /api/auth/login | ||||||
|  |  * - รับ username/password | ||||||
|  |  * - ตรวจ bcrypt แล้วออก token+refresh_token (JSON) | ||||||
|  |  * ========================= */ | ||||||
|  | r.post("/login", async (req, res) => { | ||||||
|   const { username, password } = req.body || {}; |   const { username, password } = req.body || {}; | ||||||
|   if (!username || !password) { |   if (!username || !password) { | ||||||
|     return res.status(400).json({ error: 'username and password required' }); |     return res.status(400).json({ error: "USERNAME_PASSWORD_REQUIRED" }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const user = await findUserByUsername(username); |   const user = await findUserByUsername(username); | ||||||
|   if (!user) return res.status(401).json({ error: 'INVALID_CREDENTIALS' }); |   if (!user) return res.status(401).json({ error: "INVALID_CREDENTIALS" }); | ||||||
|  |  | ||||||
|   const ok = await bcrypt.compare(password, user.password_hash || ''); |   const ok = await bcrypt.compare(password, user.password_hash || ""); | ||||||
|   if (!ok) return res.status(401).json({ error: 'INVALID_CREDENTIALS' }); |   if (!ok) return res.status(401).json({ error: "INVALID_CREDENTIALS" }); | ||||||
|  |  | ||||||
|   const access_token = signAccessToken(user); |   const token = signAccessToken(user); | ||||||
|   const refresh_token = signRefreshToken(user); |   const refresh_token = signRefreshToken(user); | ||||||
|   res.json({ |  | ||||||
|     token: access_token, |   // set httpOnly cookies (ยังคงส่ง token ใน body กลับเช่นเดิม) | ||||||
|  |   res.cookie( | ||||||
|  |     "access_token", | ||||||
|  |     token, | ||||||
|  |     cookieOpts(parseInt(process.env.ACCESS_TTL_MS || "900000", 10)) | ||||||
|  |   ); | ||||||
|  |   res.cookie( | ||||||
|  |     "refresh_token", | ||||||
|  |     refresh_token, | ||||||
|  |     cookieOpts(parseInt(process.env.REFRESH_TTL_MS || "604800000", 10)) | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   return res.json({ | ||||||
|  |     token, | ||||||
|     refresh_token, |     refresh_token, | ||||||
|     user: { |     user: { | ||||||
|       user_id: user.user_id, |       user_id: user.user_id, | ||||||
| @@ -62,53 +105,175 @@ r.post('/login', async (req, res) => { | |||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
| }); | }); | ||||||
|  | /* ========================= | ||||||
| // POST /api/auth/refresh |  * GET /api/auth/me (cookie or bearer) | ||||||
| r.post('/refresh', async (req, res) => { |  * ========================= */ | ||||||
|   const { refresh_token } = req.body || {}; | r.get("/me", requireAuth, async (req, res) => { | ||||||
|   if (!refresh_token) return res.status(400).json({ error: 'refresh_token required' }); |   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 { |     try { | ||||||
|     const payload = jwt.verify(refresh_token, REFRESH_SECRET, { issuer: 'dms-backend' }); |       const payload = jwt.verify(refreshToken, REFRESH_SECRET, { | ||||||
|     if (payload.t !== 'refresh') throw new Error('bad token'); |         issuer: "dms-backend", | ||||||
|  |       }); | ||||||
|  |       if (payload.t !== "refresh") throw new Error("bad token type"); | ||||||
|  |  | ||||||
|     // ยืนยันผู้ใช้ยังอยู่ในระบบ |  | ||||||
|       const [[user]] = await sql.query( |       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] |         [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 token = signAccessToken(user); | ||||||
|     const new_refresh = signRefreshToken(user); // rotation |       const new_refresh = signRefreshToken(user); | ||||||
|     res.json({ token, refresh_token: new_refresh }); |  | ||||||
|   } catch (e) { |       // rotate cookies | ||||||
|     return res.status(401).json({ error: 'INVALID_REFRESH', message: e?.message }); |       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) => { |  * POST /api/auth/reset-password | ||||||
|   // หากต้องการ blacklist/whitelist refresh token ให้เพิ่มตารางและบันทึกที่นี่ |  * - รับ token (จากลิงก์ในอีเมล) + new_password | ||||||
|   res.json({ ok: 1 }); |  * - ตรวจ token_hash กับ DB, หมดอายุหรือถูกใช้ไปแล้วหรือยัง | ||||||
| }); |  * - เปลี่ยนรหัสผ่าน/ปิดใช้ token | ||||||
|  |  * ========================= */ | ||||||
| // POST /api/auth/change-password | r.post("/reset-password", async (req, res) => { | ||||||
| r.post('/change-password', async (req, res) => { |   const { token, new_password } = req.body || {}; | ||||||
|   const { username, old_password, new_password } = req.body || {}; |   if (!token || !new_password) { | ||||||
|   if (!username || !old_password || !new_password) { |     return res.status(400).json({ error: "TOKEN_AND_NEW_PASSWORD_REQUIRED" }); | ||||||
|     return res.status(400).json({ error: 'username, old_password, new_password required' }); |  | ||||||
|   } |   } | ||||||
|   const user = await findUserByUsername(username); |  | ||||||
|   if (!user) return res.status(404).json({ error: 'USER_NOT_FOUND' }); |  | ||||||
|  |  | ||||||
|   const ok = await bcrypt.compare(old_password, user.password_hash || ''); |   const token_hash = crypto.createHash("sha256").update(token).digest("hex"); | ||||||
|   if (!ok) return res.status(401).json({ error: 'INVALID_CREDENTIALS' }); |  | ||||||
|  |  | ||||||
|  |   const [[row]] = await sql.query( | ||||||
|  |     `SELECT id, user_id, expires_at, used_at | ||||||
|  |      FROM password_resets | ||||||
|  |      WHERE token_hash=? LIMIT 1`, | ||||||
|  |     [token_hash] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   if (!row) return res.status(400).json({ error: "INVALID_TOKEN" }); | ||||||
|  |   if (row.used_at) return res.status(400).json({ error: "TOKEN_ALREADY_USED" }); | ||||||
|  |   if (new Date(row.expires_at).getTime() < Date.now()) { | ||||||
|  |     return res.status(400).json({ error: "TOKEN_EXPIRED" }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // เปลี่ยนรหัสผ่าน | ||||||
|   const salt = await bcrypt.genSalt(10); |   const salt = await bcrypt.genSalt(10); | ||||||
|   const hash = await bcrypt.hash(new_password, salt); |   const hash = await bcrypt.hash(new_password, salt); | ||||||
|   await sql.query('UPDATE users SET password_hash=? WHERE user_id=?', [hash, user.user_id]); |   await sql.query(`UPDATE users SET password_hash=? WHERE user_id=?`, [ | ||||||
|   res.json({ ok: 1 }); |     hash, | ||||||
|  |     row.user_id, | ||||||
|  |   ]); | ||||||
|  |  | ||||||
|  |   // ปิดใช้ token นี้ | ||||||
|  |   await sql.query(`UPDATE password_resets SET used_at=NOW() WHERE id=?`, [ | ||||||
|  |     row.id, | ||||||
|  |   ]); | ||||||
|  |  | ||||||
|  |   return res.json({ ok: true }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | /* ========================= | ||||||
|  |  * POST /api/auth/logout — stateless | ||||||
|  |  * - frontend ลบ token เอง | ||||||
|  |  * ========================= */ | ||||||
|  | r.post("/logout", (_req, res) => { | ||||||
|  |   res.clearCookie("access_token", { path: "/" }); | ||||||
|  |   res.clearCookie("refresh_token", { path: "/" }); | ||||||
|  |   return res.json({ ok: true }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
|  |  | ||||||
|  | // หมายเหตุ: คุณอาจเพิ่ม /register, /forgot-password, /reset-password ตามต้องการ | ||||||
|  | // แต่ในโปรเจกต์นี้จะให้แอดมินสร้างบัญชีผู้ใช้ผ่าน /api/users แทน | ||||||
|   | |||||||
| @@ -1,19 +1,20 @@ | |||||||
| import { Router } from 'express'; | // FILE: src/routes/auth_extras.js | ||||||
| import { requireAuth, enrichRoles } from '../middleware/auth.js'; | // Deprecated for this project (เราใช้ Bearer + authJwt() แล้ว) | ||||||
|  | import jwt from "jsonwebtoken"; | ||||||
| const r = Router(); | const JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || "dev-access-secret"; | ||||||
|  | export function requireAuth(req, res, next) { | ||||||
| r.get('/auth/me', requireAuth, enrichRoles, async (req, res) => { |   const token = req.cookies?.access_token; | ||||||
|   res.json({ |   if (!token) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|     user_id: req.user?.user_id, |   try { | ||||||
|     username: req.user?.username, |     const payload = jwt.verify(token, JWT_ACCESS_SECRET, { | ||||||
|     roles: req.user?.roles || [] |       issuer: "dms-backend", | ||||||
|     }); |     }); | ||||||
| }); |     req.user = { user_id: payload.user_id, username: payload.username }; | ||||||
|  |     return next(); | ||||||
| // Placeholder: client can simply drop tokens; provided for symmetry/logging hook |   } catch { | ||||||
| r.post('/auth/logout', requireAuth, async (_req, res) => { |     return res.status(401).json({ error: "INVALID_TOKEN" }); | ||||||
|   res.json({ ok: true }); |   } | ||||||
| }); | } | ||||||
|  | export function requireRole(_role) { | ||||||
| export default r; |   return (_req, res, next) => res.status(403).json({ error: "FORBIDDEN" }); | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,66 +1,62 @@ | |||||||
| import { Router } from 'express'; | // FILE: src/routes/categories.js | ||||||
| import sql from '../db/index.js'; | // อ่าน: ใช้ organizations.view (GLOBAL) | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | // สร้าง/แก้/ลบ: ใช้ settings.manage (GLOBAL) | ||||||
| import PERM from '../config/permissions.js'; | import { Router } from "express"; | ||||||
|  | import sql from "../db/index.js"; | ||||||
|  | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
|  |  | ||||||
|  | // Categories | ||||||
| // Category LIST (global master, not scoped) – still require permission | r.get("/categories", requirePerm("organizations.view"), async (_req, res) => { | ||||||
| r.get('/categories', |   const [rows] = await sql.query( | ||||||
| requirePerm(PERM.category.read, { scope: 'global' }), |     "SELECT * FROM categories ORDER BY cat_id DESC" | ||||||
| async (req, res) => { |   ); | ||||||
| const [rows] = await sql.query('SELECT * FROM categories ORDER BY cat_id DESC'); |   res.json(rows); | ||||||
| res.json(rows); | }); | ||||||
| } | r.post("/categories", requirePerm("settings.manage"), async (req, res) => { | ||||||
|  |   const { cat_code, cat_name } = req.body || {}; | ||||||
|  |   if (!cat_code || !cat_name) | ||||||
|  |     return res.status(400).json({ error: "cat_code and cat_name required" }); | ||||||
|  |   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 }); |   res.json({ cat_id: rs.insertId }); | ||||||
| } | }); | ||||||
| ); | r.put("/categories/:id", requirePerm("settings.manage"), async (req, res) => { | ||||||
|  |   const id = Number(req.params.id); | ||||||
|  |   const { cat_name } = req.body || {}; | ||||||
| r.put('/categories/:id', |   await sql.query("UPDATE categories SET cat_name=? WHERE cat_id=?", [ | ||||||
| requirePerm(PERM.category.update, { scope: 'global' }), |     cat_name, | ||||||
|  |     id, | ||||||
|  |   ]); | ||||||
|  |   res.json({ ok: 1 }); | ||||||
|  | }); | ||||||
|  | r.delete( | ||||||
|  |   "/categories/:id", | ||||||
|  |   requirePerm("settings.manage"), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const id = Number(req.params.id); |     const id = Number(req.params.id); | ||||||
| const { cat_name } = req.body; |     await sql.query("DELETE FROM categories WHERE cat_id=?", [id]); | ||||||
| await sql.query('UPDATE categories SET cat_name=? WHERE cat_id=?', [cat_name, id]); |  | ||||||
|     res.json({ ok: 1 }); |     res.json({ ok: 1 }); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | // Subcategories | ||||||
| r.delete('/categories/:id', | r.get("/subcategories", requirePerm("organizations.view"), async (req, res) => { | ||||||
| requirePerm(PERM.category.delete, { scope: 'global' }), |  | ||||||
| async (req, res) => { |  | ||||||
| const id = Number(req.params.id); |  | ||||||
| await sql.query('DELETE FROM categories WHERE cat_id=?', [id]); |  | ||||||
| res.json({ ok: 1 }); |  | ||||||
| } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
|  |  | ||||||
| // Subcategories (belong to categories) |  | ||||||
| r.get('/subcategories', |  | ||||||
| requirePerm(PERM.category.read, { scope: 'global' }), |  | ||||||
| async (req, res) => { |  | ||||||
|   const { cat_id } = req.query; |   const { cat_id } = req.query; | ||||||
| let sqlText = 'SELECT * FROM subcategories'; |  | ||||||
|   const params = []; |   const params = []; | ||||||
| if (cat_id) { sqlText += ' WHERE cat_id=?'; params.push(Number(cat_id)); } |   let where = ""; | ||||||
| sqlText += ' ORDER BY sub_cat_id DESC'; |   if (cat_id) { | ||||||
| const [rows] = await sql.query(sqlText, params); |     where = " WHERE cat_id=?"; | ||||||
| res.json(rows); |     params.push(Number(cat_id)); | ||||||
|   } |   } | ||||||
|  |   const [rows] = await sql.query( | ||||||
|  |     `SELECT * FROM subcategories${where} ORDER BY sub_cat_id DESC`, | ||||||
|  |     params | ||||||
|   ); |   ); | ||||||
|  |   res.json(rows); | ||||||
|  | }); | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
| @@ -1,74 +1,143 @@ | |||||||
| import { Router } from 'express'; | // FILE: src/routes/contract_dwg.js | ||||||
| import sql from '../db/index.js'; | // ใน seed ยังไม่มี contract_dwg.* → ผูกชั่วคราวกับสิทธิ์กลุ่ม drawings: | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | // read → drawings.view, create/update/delete → drawings.upload/delete (PROJECT scope) | ||||||
| import { buildScopeWhere, ownerResolvers } from '../utils/scope.js'; | import { Router } from "express"; | ||||||
| import PERM from '../config/permissions.js'; | import sql from "../db/index.js"; | ||||||
|  | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const OWN = ownerResolvers(sql, 'contract_dwg', 'id'); |  | ||||||
|  |  | ||||||
| // LIST mappings | // LIST (PROJECT scope enforce via params ifส่ง project_id, ไม่ส่งจะถูก filter ด้วย scope ภายใน) | ||||||
| r.get('/', | r.get( | ||||||
| requirePerm(PERM.contract_dwg.read, { scope: 'global' }), |   "/", | ||||||
|  |   requirePerm("drawings.view", { projectParam: "project_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const { project_id, org_id, condwg_no, limit = 50, offset = 0 } = req.query; |     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 p = req.principal; | ||||||
| const extra = []; |     const params = []; | ||||||
| const params = { ...base.params, limit: Number(limit), offset: Number(offset) }; |     const cond = []; | ||||||
| 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); } |     // ABAC filter ฝั่ง server กันหลุดขอบเขต | ||||||
| if (condwg_no) { extra.push('m.condwg_no = :condwg_no'); params.condwg_no = condwg_no; } |     if (!p.is_superadmin) { | ||||||
| const where = [base.where, ...extra].filter(Boolean).join(' AND '); |       if (project_id) { | ||||||
| const [rows] = await sql.query(`SELECT m.* FROM contract_dwg m WHERE ${where} ORDER BY m.id DESC LIMIT :limit OFFSET :offset`, params); |         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); |     res.json(rows); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | // GET item (ตรวจ ABAC หลังอ่านเรคคอร์ด) | ||||||
| // GET mapping | r.get("/:id", requirePerm("drawings.view"), async (req, res) => { | ||||||
| r.get('/:id', |  | ||||||
| requirePerm(PERM.contract_dwg.read, { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
| async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   const id = Number(req.params.id); | ||||||
| const [[row]] = await sql.query('SELECT * FROM contract_dwg WHERE id=?', [id]); |   const [[row]] = await sql.query("SELECT * FROM contract_dwg WHERE id=?", [ | ||||||
| if (!row) return res.status(404).json({ error: 'Not found' }); |     id, | ||||||
|  |   ]); | ||||||
|  |   if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|  |   const p = req.principal; | ||||||
|  |   if (!p.is_superadmin && !p.inProject(row.project_id)) | ||||||
|  |     return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|   res.json(row); |   res.json(row); | ||||||
| } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
|  | // CREATE | ||||||
| // CREATE mapping (1 drawing per contract or per rule) | r.post( | ||||||
| r.post('/', |   "/", | ||||||
| requirePerm(PERM.contract_dwg.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }), |   requirePerm("drawings.upload", { projectParam: "project_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
| const { org_id, project_id, condwg_no, title, drawing_id, volume_id, sub_cat_id, sub_no, remark } = req.body; |     const { | ||||||
| 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]); |       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 }); |     res.json({ id: rs.insertId }); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  |  | ||||||
| // UPDATE | // UPDATE | ||||||
| r.put('/:id', | r.put("/:id", requirePerm("drawings.upload"), async (req, res) => { | ||||||
| requirePerm(PERM.contract_dwg.update, { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
| async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   const id = Number(req.params.id); | ||||||
| const { title, remark } = req.body; |   const [[row]] = await sql.query("SELECT * FROM contract_dwg WHERE id=?", [ | ||||||
| await sql.query('UPDATE contract_dwg SET title=?, remark=? WHERE id=?', [title, remark, id]); |     id, | ||||||
| res.json({ ok: 1 }); |   ]); | ||||||
| } |   if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
| ); |   const p = req.principal; | ||||||
|  |   if (!p.is_superadmin && !p.inProject(row.project_id)) | ||||||
|  |     return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|  |  | ||||||
|  |   const { title, remark } = req.body || {}; | ||||||
|  |   await sql.query("UPDATE contract_dwg SET title=?, remark=? WHERE id=?", [ | ||||||
|  |     title ?? row.title, | ||||||
|  |     remark ?? row.remark, | ||||||
|  |     id, | ||||||
|  |   ]); | ||||||
|  |   res.json({ ok: 1 }); | ||||||
|  | }); | ||||||
|  |  | ||||||
| // DELETE | // DELETE | ||||||
| r.delete('/:id', | r.delete("/:id", requirePerm("drawings.delete"), async (req, res) => { | ||||||
| requirePerm(PERM.contract_dwg.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
| async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   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 }); |   res.json({ ok: 1 }); | ||||||
| } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
| @@ -1,72 +1,141 @@ | |||||||
| import { Router } from 'express'; | // FILE: src/routes/contracts.js | ||||||
| import sql from '../db/index.js'; | // ไม่มี contract.* ใน seed → map เป็นงานดูแลองค์กร/โปรเจ็กต์: | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | // list/get → projects.view (ORG) | ||||||
| import { buildScopeWhere, ownerResolvers } from '../utils/scope.js'; | // create/update/delete → projects.manage (ORG) | ||||||
| import PERM from '../config/permissions.js'; | import { Router } from "express"; | ||||||
|  | import sql from "../db/index.js"; | ||||||
|  | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const OWN = ownerResolvers(sql, 'contracts', 'id'); |  | ||||||
|  |  | ||||||
|  | // LIST | ||||||
| r.get('/', | r.get( | ||||||
| requirePerm(PERM.contract.read, { scope: 'global' }), |   "/", | ||||||
|  |   requirePerm("projects.view", { orgParam: "org_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
| const { project_id, org_id, contract_no, q, limit = 50, offset = 0 } = req.query; |     const { | ||||||
| const base = buildScopeWhere(req.principal, { tableAlias: 'c', orgColumn: 'c.org_id', projectColumn: 'c.project_id', permCode: PERM.contract.read, preferProject: true }); |       project_id, | ||||||
| const extra = []; |       org_id, | ||||||
| const params = { ...base.params, limit: Number(limit), offset: Number(offset) }; |       contract_no, | ||||||
| if (project_id) { extra.push('c.project_id = :project_id'); params.project_id = Number(project_id); } |       q, | ||||||
| if (org_id) { extra.push('c.org_id = :org_id'); params.org_id = Number(org_id); } |       limit = 50, | ||||||
| if (contract_no){ extra.push('c.contract_no = :contract_no'); params.contract_no = contract_no; } |       offset = 0, | ||||||
| if (q) { extra.push('(c.contract_no LIKE :q OR c.title LIKE :q)'); params.q = `%${q}%`; } |     } = req.query; | ||||||
| const where = [base.where, ...extra].filter(Boolean).join(' AND '); |     const p = req.principal; | ||||||
| const [rows] = await sql.query(`SELECT c.* FROM contracts c WHERE ${where} ORDER BY c.id DESC LIMIT :limit OFFSET :offset`, params); |     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); |     res.json(rows); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | // GET | ||||||
| r.get('/:id', | r.get( | ||||||
| requirePerm(PERM.contract.read, { scope: 'org', getOrgId: OWN.getOrgIdById }), |   "/:id", | ||||||
|  |   requirePerm("projects.view", { orgParam: "org_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const id = Number(req.params.id); |     const id = Number(req.params.id); | ||||||
| const [[row]] = await sql.query('SELECT * FROM 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' }); |     if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|  |     const p = req.principal; | ||||||
|  |     if (!p.is_superadmin && !p.inOrg(row.org_id)) | ||||||
|  |       return res.status(403).json({ error: "FORBIDDEN_ORG" }); | ||||||
|     res.json(row); |     res.json(row); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | // CREATE | ||||||
| r.post('/', | r.post( | ||||||
| requirePerm(PERM.contract.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }), |   "/", | ||||||
|  |   requirePerm("projects.manage", { orgParam: "org_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
| const { org_id, project_id, contract_no, title, status } = req.body; |     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]); |     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 }); |     res.json({ id: rs.insertId }); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | // UPDATE | ||||||
| r.put('/:id', | r.put( | ||||||
| requirePerm(PERM.contract.update, { scope: 'org', getOrgId: OWN.getOrgIdById }), |   "/:id", | ||||||
|  |   requirePerm("projects.manage", { orgParam: "org_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const id = Number(req.params.id); |     const id = Number(req.params.id); | ||||||
| const { title, status } = req.body; |     const [[row]] = await sql.query("SELECT * FROM contracts WHERE id=?", [id]); | ||||||
| await sql.query('UPDATE contracts SET title=?, status=? WHERE id=?', [title, status, 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 }); |     res.json({ ok: 1 }); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | // DELETE | ||||||
| r.delete('/:id', | r.delete( | ||||||
| requirePerm(PERM.contract.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }), |   "/:id", | ||||||
|  |   requirePerm("projects.manage", { orgParam: "org_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const id = Number(req.params.id); |     const id = Number(req.params.id); | ||||||
| await sql.query('DELETE FROM 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 }); |     res.json({ ok: 1 }); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
| @@ -1,74 +1,124 @@ | |||||||
| import { Router } from 'express'; | // FILE: backend/src/routes/correspondences.js | ||||||
| import sql from '../db/index.js'; | import { Router } from "express"; | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | import sql from "../db/index.js"; | ||||||
| import { buildScopeWhere, ownerResolvers } from '../utils/scope.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
| import PERM from '../config/permissions.js'; |  | ||||||
|  |  | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const OWN = ownerResolvers(sql, 'correspondences', 'id'); |  | ||||||
|  |  | ||||||
|  | // LIST | ||||||
| r.get('/', | r.get( | ||||||
| requirePerm(PERM.correspondence.read, { scope: 'global' }), |   "/", | ||||||
|  |   requirePerm("corr.view", { projectParam: "project_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const { project_id, org_id, q, limit = 50, offset = 0 } = req.query; |     const { project_id, org_id, q, limit = 50, offset = 0 } = req.query; | ||||||
| const base = buildScopeWhere(req.principal, { |     const p = req.principal; | ||||||
| tableAlias: 'c', orgColumn: 'c.org_id', projectColumn: 'c.project_id', |     const params = []; | ||||||
| permCode: PERM.correspondence.read, preferProject: true, |     const cond = []; | ||||||
| }); |  | ||||||
| const extra = []; |     if (!p.is_superadmin) { | ||||||
| const params = { ...base.params, limit: Number(limit), offset: Number(offset) }; |       if (project_id) { | ||||||
| if (project_id) { extra.push('c.project_id = :project_id'); params.project_id = Number(project_id); } |         if (!p.inProject(Number(project_id))) | ||||||
| if (org_id) { extra.push('c.org_id = :org_id'); params.org_id = Number(org_id); } |           return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
| if (q) { extra.push('(c.corr_no LIKE :q OR c.subject LIKE :q)'); params.q = `%${q}%`; } |         cond.push("c.project_id=?"); | ||||||
| const where = [base.where, ...extra].join(' AND '); |         params.push(Number(project_id)); | ||||||
| const [rows] = await sql.query(`SELECT c.* FROM correspondences c WHERE ${where} ORDER BY c.id DESC LIMIT :limit OFFSET :offset`, params); |       } 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); |     res.json(rows); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | // GET | ||||||
| r.get('/:id', | r.get("/:id", requirePerm("corr.view"), async (req, res) => { | ||||||
| requirePerm(PERM.correspondence.read, { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
| async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   const id = Number(req.params.id); | ||||||
| const [[row]] = await sql.query('SELECT * FROM correspondences WHERE id=?', [id]); |   const [[row]] = await sql.query("SELECT * FROM correspondences WHERE id=?", [ | ||||||
| if (!row) return res.status(404).json({ error: 'Not found' }); |     id, | ||||||
|  |   ]); | ||||||
|  |   if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|  |   const p = req.principal; | ||||||
|  |   if (!p.is_superadmin && !p.inProject(row.project_id)) | ||||||
|  |     return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|   res.json(row); |   res.json(row); | ||||||
| } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
|  | // CREATE | ||||||
| r.post('/', | r.post( | ||||||
| requirePerm(PERM.correspondence.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }), |   "/", | ||||||
|  |   requirePerm("corr.manage", { projectParam: "project_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
| const { org_id, project_id, corr_no, subject, status } = req.body; |     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]); |     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 }); |     res.json({ id: rs.insertId }); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | // UPDATE | ||||||
| r.put('/:id', | r.put("/:id", requirePerm("corr.manage"), async (req, res) => { | ||||||
| requirePerm(PERM.correspondence.update, { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
| async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   const id = Number(req.params.id); | ||||||
| const { subject, status } = req.body; |   const [[row]] = await sql.query("SELECT * FROM correspondences WHERE id=?", [ | ||||||
| await sql.query('UPDATE correspondences SET subject=?, status=? WHERE id=?', [subject, status, 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 }); |   res.json({ ok: 1 }); | ||||||
| } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
|  | // DELETE | ||||||
| r.delete('/:id', | r.delete("/:id", requirePerm("corr.manage"), async (req, res) => { | ||||||
| requirePerm(PERM.correspondence.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
| async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   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 }); |   res.json({ ok: 1 }); | ||||||
| } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
							
								
								
									
										56
									
								
								backend/src/routes/dashboard copy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								backend/src/routes/dashboard copy.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | |||||||
|  | // backend/src/routes/dashboard.js | ||||||
|  | import { Router } from "express"; | ||||||
|  | import { Op } from "sequelize"; | ||||||
|  | import { Correspondence, Document, RFA, User } from "../db/index.js"; // import models | ||||||
|  | import { authJwt } from "../middleware/index.js"; | ||||||
|  |  | ||||||
|  | const router = Router(); | ||||||
|  |  | ||||||
|  | // Middleware: ตรวจสอบสิทธิ์สำหรับทุก route ในไฟล์นี้ | ||||||
|  | router.use(authJwt.verifyToken); | ||||||
|  |  | ||||||
|  | // === API สำหรับ User Management Widget === | ||||||
|  | router.get("/users/summary", async (req, res, next) => { | ||||||
|  |   try { | ||||||
|  |     const totalUsers = await User.count(); | ||||||
|  |     const activeUsers = await User.count({ where: { is_active: true } }); | ||||||
|  |     // ดึง user ที่สร้างล่าสุด 5 คน | ||||||
|  |     const recentUsers = await User.findAll({ | ||||||
|  |       limit: 5, | ||||||
|  |       order: [["createdAt", "DESC"]], | ||||||
|  |       attributes: ["id", "username", "email", "createdAt"], | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       total: totalUsers, | ||||||
|  |       active: activeUsers, | ||||||
|  |       inactive: totalUsers - activeUsers, | ||||||
|  |       recent: recentUsers, | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     next(error); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // === API อื่นๆ สำหรับ Dashboard ที่เราคุยกันไว้ก่อนหน้า === | ||||||
|  | router.get("/stats", async (req, res, next) => { | ||||||
|  |   try { | ||||||
|  |     const sevenDaysAgo = new Date(new Date().setDate(new Date().getDate() - 7)); | ||||||
|  |  | ||||||
|  |     const totalDocuments = await Document.count(); | ||||||
|  |     const newThisWeek = await Document.count({ | ||||||
|  |       where: { createdAt: { [Op.gte]: sevenDaysAgo } }, | ||||||
|  |     }); | ||||||
|  |     const pendingRfas = await RFA.count({ where: { status: "pending" } }); // สมมติตาม status | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       totalDocuments, | ||||||
|  |       newThisWeek, | ||||||
|  |       pendingRfas, | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     next(error); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | export default router; | ||||||
							
								
								
									
										63
									
								
								backend/src/routes/dashboard.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										63
									
								
								backend/src/routes/dashboard.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,63 @@ | |||||||
|  | // backend/src/routes/dashboard.js | ||||||
|  | import { Router } from 'express'; | ||||||
|  | import { Op } from 'sequelize'; | ||||||
|  |  | ||||||
|  | // 1. Import Middleware ที่ถูกต้อง | ||||||
|  | import { authJwt } from '../middleware/authJwt.js'; | ||||||
|  | import { loadPrincipalMw } from '../middleware/loadPrincipal.js'; | ||||||
|  |  | ||||||
|  | // 2. Import Sequelize Models จาก `sequelize.js` ไม่ใช่ `index.js` | ||||||
|  | import { Correspondence, Document, RFA, User } from '../db/sequelize.js'; | ||||||
|  |  | ||||||
|  | const router = Router(); | ||||||
|  |  | ||||||
|  | // 3. ใช้ Middleware Chain ที่ถูกต้อง 100% | ||||||
|  | router.use(authJwt(), loadPrincipalMw()); | ||||||
|  |  | ||||||
|  |  | ||||||
|  | // === API สำหรับ User Management Widget === | ||||||
|  | router.get('/users/summary', async (req, res, next) => { | ||||||
|  |     try { | ||||||
|  |         // ตรวจสอบว่า Model ถูกโหลดแล้วหรือยัง (จำเป็นสำหรับโหมด lazy-load) | ||||||
|  |         if (!User) { | ||||||
|  |             return res.status(503).json({ message: 'Database models not available. Is ENABLE_SEQUELIZE=1 set?' }); | ||||||
|  |         } | ||||||
|  |         const totalUsers = await User.count(); | ||||||
|  |         const activeUsers = await User.count({ where: { is_active: true } }); | ||||||
|  |          | ||||||
|  |         res.json({ | ||||||
|  |             total: totalUsers, | ||||||
|  |             active: activeUsers, | ||||||
|  |             inactive: totalUsers - activeUsers, | ||||||
|  |         }); | ||||||
|  |     } catch (error) { | ||||||
|  |         next(error); | ||||||
|  |     } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  |  | ||||||
|  | // === API อื่นๆ สำหรับ Dashboard ที่เราคุยกันไว้ก่อนหน้า === | ||||||
|  | router.get('/stats', async (req, res, next) => { | ||||||
|  |     try { | ||||||
|  |         if (!Document || !RFA) { | ||||||
|  |             return res.status(503).json({ message: 'Database models not available. Is ENABLE_SEQUELIZE=1 set?' }); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const sevenDaysAgo = new Date(new Date().setDate(new Date().getDate() - 7)); | ||||||
|  |  | ||||||
|  |         const totalDocuments = await Document.count(); | ||||||
|  |         const newThisWeek = await Document.count({ where: { createdAt: { [Op.gte]: sevenDaysAgo } } }); | ||||||
|  |         const pendingRfas = await RFA.count({ where: { status: 'pending' } }); // สมมติตาม status | ||||||
|  |  | ||||||
|  |         res.json({ | ||||||
|  |             totalDocuments, | ||||||
|  |             newThisWeek, | ||||||
|  |             pendingRfas | ||||||
|  |         }); | ||||||
|  |     } catch (error) { | ||||||
|  |         next(error); | ||||||
|  |     } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export default router; | ||||||
| @@ -1,58 +1,149 @@ | |||||||
| import { Router } from 'express'; | // FILE: backend/src/routes/documents.js | ||||||
| import { requireAuth } from '../middleware/auth.js'; | import { Router } from "express"; | ||||||
| import { enrichPermissions } from '../middleware/permissions.js'; | import sql from "../db/index.js"; | ||||||
| import { requireRole } from '../middleware/rbac.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
| import { requirePerm } from '../middleware/permGuard.js'; |  | ||||||
| import { sequelize } from '../db/sequelize.js'; |  | ||||||
| import DocumentModel from '../db/models/Document.js'; |  | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const Doc = DocumentModel(sequelize); |  | ||||||
|  |  | ||||||
| r.get('/documents', requireAuth, async (req, res) => { | // LIST | ||||||
|   const { q, project_id, status, category, page=1, page_size=20 } = req.query; | r.get( | ||||||
|  |   "/", | ||||||
|  |   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 limit = Math.min(Number(page_size) || 20, 100); | ||||||
|     const offset = (Math.max(Number(page) || 1, 1) - 1) * limit; |     const offset = (Math.max(Number(page) || 1, 1) - 1) * limit; | ||||||
|  |  | ||||||
|   const where = {}; |     const p = req.principal; | ||||||
|   if (project_id) where.project_id = project_id; |     const params = []; | ||||||
|   if (status) where.status = status; |     const cond = []; | ||||||
|   if (category) where.category = category; |  | ||||||
|   if (q) where.title = sequelize.where(sequelize.fn('LOWER', sequelize.col('title')), 'LIKE', `%${String(q).toLowerCase()}%`); |  | ||||||
|  |  | ||||||
|   const { rows, count } = await Doc.findAndCountAll({ where, limit, offset, order:[['created_at','DESC']] }); |     if (!p.is_superadmin) { | ||||||
|   res.json({ items: rows, total: count, page: Number(page), page_size: limit }); |       if (project_id) { | ||||||
| }); |         if (!p.inProject(Number(project_id))) | ||||||
|  |           return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|  |         cond.push("d.project_id=?"); | ||||||
|  |         params.push(Number(project_id)); | ||||||
|  |       } else if (p.project_ids?.length) { | ||||||
|  |         cond.push( | ||||||
|  |           `d.project_id IN (${p.project_ids.map(() => "?").join(",")})` | ||||||
|  |         ); | ||||||
|  |         params.push(...p.project_ids); | ||||||
|  |       } | ||||||
|  |     } else if (project_id) { | ||||||
|  |       cond.push("d.project_id=?"); | ||||||
|  |       params.push(Number(project_id)); | ||||||
|  |     } | ||||||
|  |  | ||||||
| r.get('/documents/:id', requireAuth, async (req, res) => { |     if (status) { | ||||||
|   const row = await Doc.findByPk(Number(req.params.id)); |       cond.push("d.status=?"); | ||||||
|   if (!row) return res.status(404).json({ error: 'Not found' }); |       params.push(status); | ||||||
|  |     } | ||||||
|  |     if (category) { | ||||||
|  |       cond.push("d.category=?"); | ||||||
|  |       params.push(category); | ||||||
|  |     } | ||||||
|  |     if (q) { | ||||||
|  |       cond.push("(LOWER(d.title) LIKE ? OR d.doc_no LIKE ?)"); | ||||||
|  |       params.push(`%${String(q).toLowerCase()}%`, `%${q}%`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; | ||||||
|  |     const [[{ cnt }]] = await sql.query( | ||||||
|  |       `SELECT COUNT(*) AS cnt FROM documents d ${where}`, | ||||||
|  |       params | ||||||
|  |     ); | ||||||
|  |     const [rows] = await sql.query( | ||||||
|  |       `SELECT d.* FROM documents d ${where} ORDER BY d.created_at DESC LIMIT ? OFFSET ?`, | ||||||
|  |       [...params, limit, offset] | ||||||
|  |     ); | ||||||
|  |     res.json({ items: rows, total: cnt, page: Number(page), page_size: limit }); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | // GET | ||||||
|  | r.get("/:id", requirePerm("documents.view"), async (req, res) => { | ||||||
|  |   const id = Number(req.params.id); | ||||||
|  |   const [[row]] = await sql.query( | ||||||
|  |     "SELECT * FROM documents WHERE document_id=?", | ||||||
|  |     [id] | ||||||
|  |   ); | ||||||
|  |   if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|  |   const p = req.principal; | ||||||
|  |   if (!p.is_superadmin && !p.inProject(row.project_id)) | ||||||
|  |     return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|   res.json(row); |   res.json(row); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| r.post('/documents', requireAuth, enrichPermissions(), requireProjectMembershipFromBody(), enrichPermissions(), requirePerm('document:create'), async (req, res) => { | // CREATE | ||||||
|  | r.post( | ||||||
|  |   "/", | ||||||
|  |   requirePerm("documents.manage", { projectParam: "project_id" }), | ||||||
|  |   async (req, res) => { | ||||||
|     const { project_id, doc_no, title, category, status } = req.body || {}; |     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' }); |     if (!project_id || !doc_no) | ||||||
|   const created = await Doc.create({ project_id, doc_no, title, category, status, created_by: req.user?.user_id }); |       return res.status(400).json({ error: "project_id and doc_no required" }); | ||||||
|   res.status(201).json({ document_id: created.document_id }); |     const [rs] = await sql.query( | ||||||
| }); |       `INSERT INTO documents (project_id, doc_no, title, category, status, created_by) | ||||||
|  |      VALUES (?,?,?,?,?,?)`, | ||||||
|  |       [ | ||||||
|  |         project_id, | ||||||
|  |         doc_no, | ||||||
|  |         title || null, | ||||||
|  |         category || null, | ||||||
|  |         status || null, | ||||||
|  |         req.principal.user_id, | ||||||
|  |       ] | ||||||
|  |     ); | ||||||
|  |     res.status(201).json({ document_id: rs.insertId }); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | // UPDATE | ||||||
|  | r.patch("/:id", requirePerm("documents.manage"), async (req, res) => { | ||||||
|  |   const id = Number(req.params.id); | ||||||
|  |   const [[row]] = await sql.query( | ||||||
|  |     "SELECT * FROM documents WHERE document_id=?", | ||||||
|  |     [id] | ||||||
|  |   ); | ||||||
|  |   if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|  |   const p = req.principal; | ||||||
|  |   if (!p.is_superadmin && !p.inProject(row.project_id)) | ||||||
|  |     return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|  |  | ||||||
| r.patch('/documents/:id', requireAuth, enrichPermissions(), requireProjectMembershipByRecord({ modelLoader: (s)=> (await import('../db/models/Document.js')).default(s) }), enrichPermissions(), requirePerm('document:update'), async (req, res) => { |  | ||||||
|   const row = await Doc.findByPk(Number(req.params.id)); |  | ||||||
|   if (!row) return res.status(404).json({ error: 'Not found' }); |  | ||||||
|   const { title, category, status } = req.body || {}; |   const { title, category, status } = req.body || {}; | ||||||
|   if (title !== undefined) row.title = title; |   await sql.query( | ||||||
|   if (category !== undefined) row.category = category; |     "UPDATE documents SET title=?, category=?, status=?, updated_by=? WHERE document_id=?", | ||||||
|   if (status !== undefined) row.status = status; |     [ | ||||||
|   row.updated_by = req.user?.user_id; |       title ?? row.title, | ||||||
|   await row.save(); |       category ?? row.category, | ||||||
|  |       status ?? row.status, | ||||||
|  |       req.principal.user_id, | ||||||
|  |       id, | ||||||
|  |     ] | ||||||
|  |   ); | ||||||
|   res.json({ ok: true }); |   res.json({ ok: true }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| r.delete('/documents/:id', requireAuth, enrichPermissions(), requireProjectMembershipByRecord({ modelLoader: (s)=> (await import('../db/models/Document.js')).default(s) }), enrichPermissions(), requirePerm('document:delete'), async (req, res) => { | // DELETE | ||||||
|   const row = await Doc.findByPk(Number(req.params.id)); | r.delete("/:id", requirePerm("documents.manage"), async (req, res) => { | ||||||
|   if (!row) return res.status(404).json({ error: 'Not found' }); |   const id = Number(req.params.id); | ||||||
|   await row.destroy(); |   const [[row]] = await sql.query( | ||||||
|  |     "SELECT * FROM documents WHERE document_id=?", | ||||||
|  |     [id] | ||||||
|  |   ); | ||||||
|  |   if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|  |   const p = req.principal; | ||||||
|  |   if (!p.is_superadmin && !p.inProject(row.project_id)) | ||||||
|  |     return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|  |   await sql.query("DELETE FROM documents WHERE document_id=?", [id]); | ||||||
|   res.json({ ok: true }); |   res.json({ ok: true }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,85 +1,120 @@ | |||||||
| import { Router } from 'express'; | // FILE: backend/src/routes/drawings.js | ||||||
| import sql from '../db/index.js'; | import { Router } from "express"; | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | import sql from "../db/index.js"; | ||||||
| import { buildScopeWhere, ownerResolvers } from '../utils/scope.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
| import PERM from '../config/permissions.js'; |  | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const OWN = ownerResolvers(sql, 'drawings', 'id'); |  | ||||||
|  |  | ||||||
| // LIST | // LIST | ||||||
| r.get('/', | r.get( | ||||||
|   requirePerm('drawing.read', { scope: 'global' }), |   "/", | ||||||
|  |   requirePerm("drawings.view", { projectParam: "project_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const { project_id, org_id, code, q, limit = 50, offset = 0 } = req.query; |     const { project_id, org_id, code, q, limit = 50, offset = 0 } = req.query; | ||||||
|  |     const p = req.principal; | ||||||
|  |     const params = []; | ||||||
|  |     const cond = []; | ||||||
|  |  | ||||||
|     const base = buildScopeWhere(req.principal, { |     if (!p.is_superadmin) { | ||||||
|       tableAlias: 'd', orgColumn: 'd.org_id', projectColumn: 'd.project_id', |       if (project_id) { | ||||||
|       permCode: 'drawing.read', preferProject: true, |         if (!p.inProject(Number(project_id))) | ||||||
|     }); |           return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|  |         cond.push("d.project_id=?"); | ||||||
|  |         params.push(Number(project_id)); | ||||||
|  |       } else if (p.project_ids?.length) { | ||||||
|  |         cond.push( | ||||||
|  |           `d.project_id IN (${p.project_ids.map(() => "?").join(",")})` | ||||||
|  |         ); | ||||||
|  |         params.push(...p.project_ids); | ||||||
|  |       } | ||||||
|  |     } else if (project_id) { | ||||||
|  |       cond.push("d.project_id=?"); | ||||||
|  |       params.push(Number(project_id)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     const extra = []; |     if (org_id) { | ||||||
|     const params = { ...base.params, limit: Number(limit), offset: Number(offset) }; |       cond.push("d.org_id=?"); | ||||||
|     if (project_id) { extra.push('d.project_id = :project_id'); params.project_id = Number(project_id); } |       params.push(Number(org_id)); | ||||||
|     if (org_id)     { extra.push('d.org_id = :org_id');         params.org_id = Number(org_id); } |     } | ||||||
|     if (code)       { extra.push('d.dwg_code = :code');         params.code = code; } |     if (code) { | ||||||
|     if (q)          { extra.push('(d.dwg_no LIKE :q OR d.title LIKE :q)'); params.q = `%${q}%`; } |       cond.push("d.dwg_code=?"); | ||||||
|  |       params.push(code); | ||||||
|     const where = [base.where, ...extra].filter(Boolean).join(' AND '); |     } | ||||||
|  |     if (q) { | ||||||
|  |       cond.push("(d.dwg_no LIKE ? OR d.title LIKE ?)"); | ||||||
|  |       params.push(`%${q}%`, `%${q}%`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; | ||||||
|     const [rows] = await sql.query( |     const [rows] = await sql.query( | ||||||
|       `SELECT d.* FROM drawings d WHERE ${where} |       `SELECT d.* FROM drawings d ${where} ORDER BY d.id DESC LIMIT ? OFFSET ?`, | ||||||
|        ORDER BY d.id DESC LIMIT :limit OFFSET :offset`, |       [...params, Number(limit), Number(offset)] | ||||||
|       params |  | ||||||
|     ); |     ); | ||||||
|     res.json(rows); |     res.json(rows); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| // GET | // GET | ||||||
| r.get('/:id', | r.get("/:id", requirePerm("drawings.view"), async (req, res) => { | ||||||
|   requirePerm('drawing.read', { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
|   async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   const id = Number(req.params.id); | ||||||
|     const [[row]] = await sql.query('SELECT * 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' }); |   if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|  |   const p = req.principal; | ||||||
|  |   if (!p.is_superadmin && !p.inProject(row.project_id)) | ||||||
|  |     return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|   res.json(row); |   res.json(row); | ||||||
|   } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
| // CREATE | // CREATE | ||||||
| r.post('/', | r.post( | ||||||
|   requirePerm('drawing.create', { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }), |   "/", | ||||||
|  |   requirePerm("drawings.upload", { projectParam: "project_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const { org_id, project_id, dwg_no, dwg_code, title } = req.body; |     const { org_id, project_id, dwg_no, dwg_code, title } = req.body || {}; | ||||||
|  |     if (!project_id || !dwg_no) | ||||||
|  |       return res.status(400).json({ error: "project_id and dwg_no required" }); | ||||||
|     const [rs] = await sql.query( |     const [rs] = await sql.query( | ||||||
|       `INSERT INTO drawings (org_id, project_id, dwg_no, dwg_code, title, created_by) |       `INSERT INTO drawings (org_id, project_id, dwg_no, dwg_code, title, created_by) | ||||||
|      VALUES (?,?,?,?,?,?)`, |      VALUES (?,?,?,?,?,?)`, | ||||||
|       [org_id, project_id, dwg_no, dwg_code, title, req.principal.userId] |       [ | ||||||
|  |         org_id || null, | ||||||
|  |         project_id, | ||||||
|  |         dwg_no, | ||||||
|  |         dwg_code || null, | ||||||
|  |         title || null, | ||||||
|  |         req.principal.user_id, | ||||||
|  |       ] | ||||||
|     ); |     ); | ||||||
|     res.json({ id: rs.insertId }); |     res.json({ id: rs.insertId }); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| // UPDATE | // UPDATE  (ใช้สิทธิ์ drawings.upload) | ||||||
| r.put('/:id', | r.put("/:id", requirePerm("drawings.upload"), async (req, res) => { | ||||||
|   requirePerm('drawing.update', { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
|   async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   const id = Number(req.params.id); | ||||||
|     const { title } = req.body; |   const [[row]] = await sql.query("SELECT * FROM drawings WHERE id=?", [id]); | ||||||
|     await sql.query('UPDATE drawings SET title=? WHERE id=?', [title, 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 }); |   res.json({ ok: 1 }); | ||||||
|   } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
| // DELETE | // DELETE | ||||||
| r.delete('/:id', | r.delete("/:id", requirePerm("drawings.delete"), async (req, res) => { | ||||||
|   requirePerm('drawing.delete', { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
|   async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   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 }); |   res.json({ ok: 1 }); | ||||||
|   } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
|   | |||||||
| @@ -1,91 +1,154 @@ | |||||||
| import { Router } from 'express'; | // FILE: backend/src/routes/files_extras.js | ||||||
| import fs from 'fs'; | // NOTE: generic file actions – ผูกสิทธิ์ตามโมดูลปลายทาง และบังคับ ABAC จาก project_id ของเรคคอร์ด | ||||||
| import path from 'path'; | import { Router } from "express"; | ||||||
| import jwt from 'jsonwebtoken'; | import fs from "node:fs"; | ||||||
| import { requireAuth } from '../middleware/auth.js'; | import sql from "../db/index.js"; | ||||||
| import { enrichPermissions } from '../middleware/permissions.js'; |  | ||||||
| import { requireRole } from '../middleware/rbac.js'; |  | ||||||
| import { requirePerm } from '../middleware/permGuard.js'; |  | ||||||
| import { sequelize } from '../db/sequelize.js'; |  | ||||||
| import FileModel from '../db/models/FileObject.js'; |  | ||||||
| import { config } from '../config.js'; |  | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const Files = FileModel(sequelize); |  | ||||||
|  |  | ||||||
| async function projectForFile(rec) { | async function projectForFile(rec) { | ||||||
|   const mod = rec.module; const refId = rec.ref_id; |   // โปรเจ็คของไฟล์อิงโมดูล/ตารางอ้างอิง | ||||||
|   switch (mod) { |   switch (rec.module) { | ||||||
|     case 'rfa': { const M = (await import('../db/models/RFA.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; } |     case "rfa": { | ||||||
|     case 'correspondence': { const M = (await import('../db/models/Correspondence.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; } |       const [[row]] = await sql.query( | ||||||
|     case 'drawing': { const M = (await import('../db/models/Drawing.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; } |         "SELECT project_id FROM rfas WHERE id=?", | ||||||
|     case 'document': { const M = (await import('../db/models/Document.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; } |         [rec.ref_id] | ||||||
|     case 'transmittal': { const M = (await import('../db/models/Transmittal.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; } |       ); | ||||||
|     default: return null; |       return row?.project_id || null; | ||||||
|  |     } | ||||||
|  |     case "correspondence": { | ||||||
|  |       const [[row]] = await sql.query( | ||||||
|  |         "SELECT project_id FROM correspondences WHERE id=?", | ||||||
|  |         [rec.ref_id] | ||||||
|  |       ); | ||||||
|  |       return row?.project_id || null; | ||||||
|  |     } | ||||||
|  |     case "drawing": { | ||||||
|  |       const [[row]] = await sql.query( | ||||||
|  |         "SELECT project_id FROM drawings WHERE id=?", | ||||||
|  |         [rec.ref_id] | ||||||
|  |       ); | ||||||
|  |       return row?.project_id || null; | ||||||
|  |     } | ||||||
|  |     case "document": { | ||||||
|  |       const [[row]] = await sql.query( | ||||||
|  |         "SELECT project_id FROM documents WHERE document_id=?", | ||||||
|  |         [rec.ref_id] | ||||||
|  |       ); | ||||||
|  |       return row?.project_id || null; | ||||||
|  |     } | ||||||
|  |     case "transmittal": { | ||||||
|  |       const [[row]] = await sql.query( | ||||||
|  |         "SELECT project_id FROM transmittals WHERE id=?", | ||||||
|  |         [rec.ref_id] | ||||||
|  |       ); | ||||||
|  |       return row?.project_id || null; | ||||||
|  |     } | ||||||
|  |     default: | ||||||
|  |       return null; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | function permForFile(rec, action /* 'read'|'update'|'delete' */) { | ||||||
|  |   // map เป็น permission ของโมดูลจริง | ||||||
|  |   const m = rec.module; | ||||||
|  |   if (m === "document") | ||||||
|  |     return action === "read" ? "documents.view" : "documents.manage"; | ||||||
|  |   if (m === "drawing") | ||||||
|  |     return action === "read" | ||||||
|  |       ? "drawings.view" | ||||||
|  |       : action === "delete" | ||||||
|  |       ? "drawings.delete" | ||||||
|  |       : "drawings.upload"; | ||||||
|  |   if (m === "correspondence") | ||||||
|  |     return action === "read" ? "corr.view" : "corr.manage"; | ||||||
|  |   if (m === "rfa") return action === "read" ? "rfas.view" : "rfas.respond"; | ||||||
|  |   if (m === "transmittal") return "transmittals.manage"; | ||||||
|  |   return "documents.manage"; // fallback | ||||||
|  | } | ||||||
|  |  | ||||||
| // HEAD meta only | // HEAD meta | ||||||
| r.head('/files/:file_id', requireAuth, async (req, res) => { | r.head("/files/:file_id", async (req, res) => { | ||||||
|   const rec = await Files.findByPk(Number(req.params.file_id)); |   const id = Number(req.params.file_id); | ||||||
|  |   const [[rec]] = await sql.query("SELECT * FROM files WHERE file_id=?", [id]); | ||||||
|   if (!rec) return res.status(404).end(); |   if (!rec) return res.status(404).end(); | ||||||
|   res.setHeader('Content-Type', rec.mime || 'application/octet-stream'); |   res.setHeader("Content-Type", rec.mime || "application/octet-stream"); | ||||||
|   res.setHeader('Content-Length', String(rec.size || 0)); |   res.setHeader("Content-Length", String(rec.size || 0)); | ||||||
|   res.status(200).end(); |   res.status(200).end(); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| // delete (soft delete is recommended; here we do physical delete + record delete) | // DELETE | ||||||
| r.delete('/files/:file_id', requireAuth, enrichPermissions(), requirePerm('file:delete'), async (req, res) => { | r.delete("/files/:file_id", async (req, res) => { | ||||||
|   const rec = await Files.findByPk(Number(req.params.file_id)); |   const id = Number(req.params.file_id); | ||||||
|   if (!rec) return res.status(404).json({ error: 'Not found' }); |   const [[rec]] = await sql.query("SELECT * FROM files WHERE file_id=?", [id]); | ||||||
|  |   if (!rec) return res.status(404).json({ error: "Not found" }); | ||||||
|  |  | ||||||
|  |   const p = req.principal; | ||||||
|  |   if (!p) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|  |  | ||||||
|   const pid = await projectForFile(rec); |   const pid = await projectForFile(rec); | ||||||
|   const roles = req.user?.roles || []; |   if (!p.is_superadmin) { | ||||||
|   const isAdmin = roles.includes('Admin'); |     if (!pid || !p.inProject(pid)) | ||||||
|   if (!isAdmin) { |       return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|     const { getUserProjectIds } = await import('../middleware/abac.js'); |     const need = permForFile(rec, "delete"); | ||||||
|     const memberProjects = await getUserProjectIds(req.user?.user_id); |     if (!p.can?.(need)) | ||||||
|     if (!memberProjects.includes(pid)) return res.status(403).json({ error: 'Forbidden: not a project member' }); |       return res.status(403).json({ error: "FORBIDDEN", need }); | ||||||
|   } |   } | ||||||
|   try { fs.unlinkSync(rec.disk_path); } catch {} |  | ||||||
|   await rec.destroy(); |   try { | ||||||
|  |     if (rec.disk_path) fs.unlinkSync(rec.disk_path); | ||||||
|  |   } catch {} | ||||||
|  |   await sql.query("DELETE FROM files WHERE file_id=?", [id]); | ||||||
|   res.json({ ok: true }); |   res.json({ ok: true }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| // rename (meta only - keep disk file name) | // RENAME (meta only) | ||||||
| r.post('/files/:file_id/rename', requireAuth, enrichPermissions(), requirePerm('file:update'), async (req, res) => { | r.post("/files/:file_id/rename", async (req, res) => { | ||||||
|   const rec = await Files.findByPk(Number(req.params.file_id)); |   const id = Number(req.params.file_id); | ||||||
|   if (!rec) return res.status(404).json({ error: 'Not found' }); |   const [[rec]] = await sql.query("SELECT * FROM files WHERE file_id=?", [id]); | ||||||
|  |   if (!rec) return res.status(404).json({ error: "Not found" }); | ||||||
|  |  | ||||||
|  |   const p = req.principal; | ||||||
|  |   if (!p) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|  |  | ||||||
|   const pid = await projectForFile(rec); |   const pid = await projectForFile(rec); | ||||||
|   const roles = req.user?.roles || []; |   if (!p.is_superadmin) { | ||||||
|   const isAdmin = roles.includes('Admin'); |     if (!pid || !p.inProject(pid)) | ||||||
|   if (!isAdmin) { |       return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|     const { getUserProjectIds } = await import('../middleware/abac.js'); |     const need = permForFile(rec, "update"); | ||||||
|     const memberProjects = await getUserProjectIds(req.user?.user_id); |     if (!p.can?.(need)) | ||||||
|     if (!memberProjects.includes(pid)) return res.status(403).json({ error: 'Forbidden: not a project member' }); |       return res.status(403).json({ error: "FORBIDDEN", need }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const { orig_name } = req.body || {}; |   const { orig_name } = req.body || {}; | ||||||
|   if (!orig_name) return res.status(400).json({ error: 'orig_name required' }); |   if (!orig_name) return res.status(400).json({ error: "orig_name required" }); | ||||||
|   rec.orig_name = orig_name; |   await sql.query("UPDATE files SET orig_name=? WHERE file_id=?", [ | ||||||
|   await rec.save(); |     orig_name, | ||||||
|  |     id, | ||||||
|  |   ]); | ||||||
|   res.json({ ok: true }); |   res.json({ ok: true }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| // refresh signed download url | // refresh signed download URL – ปกติใช้ signed URL service ภายนอก; ที่นี่คืน URL ภายในเป็นตัวอย่าง | ||||||
| r.post('/files/:file_id/refresh-url', requireAuth, async (req, res) => { | r.post("/files/:file_id/refresh-url", async (req, res) => { | ||||||
|   const rec = await Files.findByPk(Number(req.params.file_id)); |   const id = Number(req.params.file_id); | ||||||
|   if (!rec) return res.status(404).json({ error: 'Not found' }); |   const [[rec]] = await sql.query("SELECT * FROM files WHERE file_id=?", [id]); | ||||||
|  |   if (!rec) return res.status(404).json({ error: "Not found" }); | ||||||
|  |  | ||||||
|  |   const p = req.principal; | ||||||
|  |   if (!p) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|  |  | ||||||
|   const pid = await projectForFile(rec); |   const pid = await projectForFile(rec); | ||||||
|   const roles = req.user?.roles || []; |   if (!p.is_superadmin) { | ||||||
|   const isAdmin = roles.includes('Admin'); |     if (!pid || !p.inProject(pid)) | ||||||
|   if (!isAdmin) { |       return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|     const { getUserProjectIds } = await import('../middleware/abac.js'); |     const need = permForFile(rec, "read"); | ||||||
|     const memberProjects = await getUserProjectIds(req.user?.user_id); |     if (!p.can?.(need)) | ||||||
|     if (!memberProjects.includes(pid)) return res.status(403).json({ error: 'Forbidden: not a project member' }); |       return res.status(403).json({ error: "FORBIDDEN", need }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const expSec = Number(process.env.FILE_URL_EXPIRES || 600); |   const expSec = Number(process.env.FILE_URL_EXPIRES || 600); | ||||||
|   const token = jwt.sign({ file_id: rec.file_id }, config.JWT.SECRET, { expiresIn: expSec }); |   const download_url = `/api/files/${rec.file_id}?token=internal-placeholder&exp=${expSec}`; | ||||||
|   const download_url = `/api/v1/files/${rec.file_id}?token=${token}`; |  | ||||||
|   res.json({ download_url, expires_in: expSec }); |   res.json({ download_url, expires_in: expSec }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,13 +1,19 @@ | |||||||
| import { Router } from 'express'; | // FILE: backend/src/routes/health.js | ||||||
| import { sequelize } from '../db/sequelize.js'; | import { Router } from "express"; | ||||||
|  | import sql from "../db/index.js"; | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| r.get('/health', async (_req, res) => { |  | ||||||
|  | // /api/health — ไม่ต้องใช้สิทธิ์ | ||||||
|  | r.get("/health", async (_req, res) => { | ||||||
|   try { |   try { | ||||||
|     await sequelize.query('SELECT 1 AS ok'); |     const [[{ now }]] = await sql.query("SELECT NOW() AS now"); | ||||||
|     res.status(200).json({ ok: true, db: 'up' }); |     res.status(200).json({ ok: true, db: "up", now }); | ||||||
|   } catch (e) { |   } catch (e) { | ||||||
|     res.status(500).json({ ok: false, db: 'down', error: String(e) }); |     res | ||||||
|  |       .status(500) | ||||||
|  |       .json({ ok: false, db: "down", error: String(e?.message || e) }); | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
|   | |||||||
| @@ -1,41 +0,0 @@ | |||||||
|  Volume in drive S is Container |  | ||||||
|  Volume Serial Number is 1F5F-1DEB |  | ||||||
|  |  | ||||||
|  Directory of S:\backend\src\routes |  | ||||||
|  |  | ||||||
| 11 Sep 25  16:34    <DIR>          . |  | ||||||
| 11 Sep 25  16:33    <DIR>          .. |  | ||||||
| 10 Sep 25  16:42             1,713 auth.js |  | ||||||
| 11 Sep 25  16:20             1,097 users.js |  | ||||||
| 11 Sep 25  16:20             2,624 correspondences.js |  | ||||||
| 08 Sep 25  08:58             1,306 rfa.js |  | ||||||
| 11 Sep 25  16:20             2,671 transmittals.js |  | ||||||
| 08 Sep 25  08:58             2,448 technicaldocs.js |  | ||||||
| 08 Sep 25  08:58             2,410 contractdwg.js |  | ||||||
| 08 Sep 25  08:46             2,234 admin.js |  | ||||||
| 10 Sep 25  16:42               359 health.js |  | ||||||
| 10 Sep 25  17:35               501 auth_extras.js |  | ||||||
| 10 Sep 25  18:18             2,980 documents.js |  | ||||||
| 11 Sep 25  16:20             2,848 drawings.js |  | ||||||
| 10 Sep 25  18:18             4,514 files_extras.js |  | ||||||
| 10 Sep 25  17:35             1,222 lookups.js |  | ||||||
| 10 Sep 25  18:18             4,285 maps.js |  | ||||||
| 10 Sep 25  17:35               630 module_files.js |  | ||||||
| 11 Sep 25  16:23             5,182 mvp.js |  | ||||||
| 10 Sep 25  17:35               729 ops.js |  | ||||||
| 11 Sep 25  16:20             1,197 organizations.js |  | ||||||
| 11 Sep 25  16:20             2,266 projects.js |  | ||||||
| 10 Sep 25  17:42             3,604 rbac_admin.js |  | ||||||
| 11 Sep 25  16:20             2,826 rfas.js |  | ||||||
| 10 Sep 25  17:53             2,376 subcategories.js |  | ||||||
| 11 Sep 25  16:20             2,090 uploads.js |  | ||||||
| 10 Sep 25  17:35             2,320 users_extras.js |  | ||||||
| 10 Sep 25  18:18             4,766 views.js |  | ||||||
| 11 Sep 25  16:20             1,199 volumes.js |  | ||||||
| 11 Sep 25  16:20               479 permissions.js |  | ||||||
| 11 Sep 25  16:20             2,751 contracts.js |  | ||||||
| 11 Sep 25  16:20             2,630 contract_dwg.js |  | ||||||
| 11 Sep 25  16:20             1,782 categories.js |  | ||||||
| 11 Sep 25  16:34               148 list.txt |  | ||||||
|               32 File(s)         70,187 bytes |  | ||||||
|                2 Dir(s)  3,725,382,303,744 bytes free |  | ||||||
| @@ -1,120 +1,125 @@ | |||||||
| // src/routes/lookup.js (ESM) | // FILE: backend/src/routes/lookup.js | ||||||
| import { Router } from 'express'; | import { Router } from "express"; | ||||||
| import sql from '../db/index.js'; | import sql from "../db/index.js"; | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
| import { buildScopeWhere } from '../utils/scope.js'; |  | ||||||
| import PERM from '../config/permissions.js'; |  | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * ช่วยอ่าน query pick (คั่นด้วย comma) |  | ||||||
|  */ |  | ||||||
| function parsePick(qs) { |  | ||||||
|   if (!qs) return null; |  | ||||||
|   return String(qs) |  | ||||||
|     .split(',') |  | ||||||
|     .map(s => s.trim().toLowerCase()) |  | ||||||
|     .filter(Boolean); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GET /api/lookup?pick=org,project,category,subcategory,volume,permission | // GET /api/lookup?pick=org,project,category,subcategory,volume,permission | ||||||
| r.get('/', | r.get("/", async (req, res) => { | ||||||
|   // ต้องเป็นผู้ใช้ที่ยืนยันตัวตนแล้ว (middleware authJwt+loadPrincipal อยู่ใน /api) |   const picks = new Set( | ||||||
|   // ไม่ require permission เดียว เพราะเราจะเช็คทีละชุดด้านล่าง |     String( | ||||||
|   async (req, res) => { |       req.query.pick || "org,project,category,subcategory,volume,permission" | ||||||
|     const pick = new Set(parsePick(req.query.pick) || [ |     ) | ||||||
|       'org', 'project', 'category', 'subcategory', 'volume', 'permission' |       .split(",") | ||||||
|     ]); |       .map((s) => s.trim().toLowerCase()) | ||||||
|  |       .filter(Boolean) | ||||||
|     const result = {}; |  | ||||||
|  |  | ||||||
|     // 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 |  | ||||||
|   ); |   ); | ||||||
|         result.projects = rows; |  | ||||||
|       } else { |   const out = {}; | ||||||
|         result.projects = []; |  | ||||||
|  |   // 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 |   // Projects — ORG scope (projects.view) | ||||||
|     if (pick.has('category')) { |   if (picks.has("project")) { | ||||||
|       const can = req.principal.isSuperAdmin || req.principal.perms.has(PERM.category.read); |     try { | ||||||
|       if (can) { |       const ok = | ||||||
|         const [rows] = await sql.query('SELECT cat_id, cat_code, cat_name FROM categories ORDER BY cat_name'); |         req.principal?.is_superadmin || | ||||||
|         result.categories = rows; |         req.principal?.permissions?.has?.("projects.view"); | ||||||
|       } else { |       if (!ok) out.projects = []; | ||||||
|         result.categories = []; |       else { | ||||||
|       } |         // จำกัดตาม org scope | ||||||
|     } |         const p = req.principal; | ||||||
|  |         let rows = []; | ||||||
|     // 4) Subcategories (global master) — require category.read |         if (p.is_superadmin) { | ||||||
|     if (pick.has('subcategory')) { |           [rows] = await sql.query( | ||||||
|       const can = req.principal.isSuperAdmin || req.principal.perms.has(PERM.category.read); |             "SELECT project_id, org_id, project_code, project_name FROM projects ORDER BY project_name" | ||||||
|       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); |  | ||||||
|   } |  | ||||||
|           ); |           ); | ||||||
|  |         } else if (p.org_ids?.length) { | ||||||
|  |           const inSql = p.org_ids.map(() => "?").join(","); | ||||||
|  |           [rows] = await sql.query( | ||||||
|  |             `SELECT project_id, org_id, project_code, project_name | ||||||
|  |              FROM projects WHERE org_id IN (${inSql}) | ||||||
|  |              ORDER BY project_name`, | ||||||
|  |             p.org_ids | ||||||
|  |           ); | ||||||
|  |         } else { | ||||||
|  |           rows = []; | ||||||
|  |         } | ||||||
|  |         out.projects = rows; | ||||||
|  |       } | ||||||
|  |     } catch { | ||||||
|  |       out.projects = []; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Categories/Subcategories/Volumes — GLOBAL master (settings.manage ไม่จำเป็นสำหรับการอ่าน lookup) | ||||||
|  |   if (picks.has("category")) { | ||||||
|  |     try { | ||||||
|  |       out.categories = ( | ||||||
|  |         await sql.query( | ||||||
|  |           "SELECT cat_id, cat_code, cat_name FROM categories ORDER BY cat_name" | ||||||
|  |         ) | ||||||
|  |       )[0]; | ||||||
|  |     } catch { | ||||||
|  |       out.categories = []; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   if (picks.has("subcategory")) { | ||||||
|  |     try { | ||||||
|  |       out.subcategories = ( | ||||||
|  |         await sql.query( | ||||||
|  |           "SELECT sub_cat_id, cat_id, sub_cat_name FROM subcategories ORDER BY sub_cat_name" | ||||||
|  |         ) | ||||||
|  |       )[0]; | ||||||
|  |     } catch { | ||||||
|  |       out.subcategories = []; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   if (picks.has("volume")) { | ||||||
|  |     try { | ||||||
|  |       out.volumes = ( | ||||||
|  |         await sql.query( | ||||||
|  |           "SELECT volume_id, volume_code, volume_name FROM volumes ORDER BY volume_code" | ||||||
|  |         ) | ||||||
|  |       )[0]; | ||||||
|  |     } catch { | ||||||
|  |       out.volumes = []; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Permissions — GLOBAL (settings.manage เท่านั้นที่ควรเห็นทั้งหมด) | ||||||
|  |   if (picks.has("permission")) { | ||||||
|  |     const ok = | ||||||
|  |       req.principal?.is_superadmin || | ||||||
|  |       req.principal?.permissions?.has?.("settings.manage"); | ||||||
|  |     out.permissions = ok | ||||||
|  |       ? ( | ||||||
|  |           await sql.query( | ||||||
|  |             "SELECT permission_id, perm_code AS permission_code, description FROM permissions ORDER BY perm_code" | ||||||
|  |           ) | ||||||
|  |         )[0] | ||||||
|  |       : []; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   res.json(out); | ||||||
|  | }); | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
|   | |||||||
| @@ -1,83 +1,163 @@ | |||||||
| import { Router } from 'express'; | // FILE: backend/src/routes/maps.js | ||||||
| import { requireAuth } from '../middleware/auth.js'; | // Map ความสัมพันธ์ระหว่าง RFA<->Drawing และ Correspondence<->Document | ||||||
| import { enrichPermissions } from '../middleware/permissions.js'; | import { Router } from "express"; | ||||||
| import { requireRole } from '../middleware/rbac.js'; | import sql from "../db/index.js"; | ||||||
| import { requirePerm } from '../middleware/permGuard.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
| import { sequelize } from '../db/sequelize.js'; |  | ||||||
| import RfaModel from '../db/models/RFA.js'; |  | ||||||
| import DrawingModel from '../db/models/Drawing.js'; |  | ||||||
| import RfaDrawMapModel from '../db/models/RfaDrawingMap.js'; |  | ||||||
| import CorrModel from '../db/models/Correspondence.js'; |  | ||||||
| import DocModel from '../db/models/Document.js'; |  | ||||||
| import CorrDocMapModel from '../db/models/CorrDocumentMap.js'; |  | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const RFA = RfaModel(sequelize); |  | ||||||
| const Drawing = DrawingModel(sequelize); |  | ||||||
| const RfaDraw = RfaDrawMapModel(sequelize); |  | ||||||
| const Corr = CorrModel(sequelize); |  | ||||||
| const Doc = DocModel(sequelize); |  | ||||||
| const CorrDoc = CorrDocMapModel(sequelize); |  | ||||||
|  |  | ||||||
| async function ensureRfaMembership(req, res) { | // ========= RFA <-> Drawing  ========= | ||||||
|   const rfaId = Number(req.params.rfa_id); | // LIST | ||||||
|   const row = await RFA.findByPk(rfaId); | r.get( | ||||||
|   if (!row) { res.status(404).json({ error:'RFA not found' }); return false; } |   "/maps/rfa/:rfa_id/drawings", | ||||||
|   const roles = req.user?.roles || []; const isAdmin = roles.includes('Admin'); |   requirePerm("rfas.view", { projectParam: "project_id" }), // ABAC enforced เมื่อส่ง query project_id; ถ้าไม่ส่งเราจะตรวจจากเรคคอร์ด | ||||||
|   if (isAdmin) return true; |   async (req, res) => { | ||||||
|   const { getUserProjectIds } = await import('../middleware/abac.js'); |     const rfa_id = Number(req.params.rfa_id); | ||||||
|   const memberProjects = await getUserProjectIds(req.user?.user_id); |     const [[rfa]] = await sql.query("SELECT project_id FROM rfas WHERE id=?", [ | ||||||
|   if (!memberProjects.includes(Number(row.project_id))) { res.status(403).json({ error:'Forbidden: not a project member' }); return false; } |       rfa_id, | ||||||
|   return true; |     ]); | ||||||
|  |     if (!rfa) return res.status(404).json({ error: "RFA not found" }); | ||||||
|  |     if ( | ||||||
|  |       !req.principal.is_superadmin && | ||||||
|  |       !req.principal.inProject(rfa.project_id) | ||||||
|  |     ) { | ||||||
|  |       return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|     } |     } | ||||||
|  |     const [rows] = await sql.query( | ||||||
| async function ensureCorrMembership(req, res) { |       `SELECT m.* FROM rfa_drawing_map m WHERE m.rfa_id=? ORDER BY m.id DESC`, | ||||||
|   const corrId = Number(req.params.corr_id); |       [rfa_id] | ||||||
|   const row = await Corr.findByPk(corrId); |     ); | ||||||
|   if (!row) { res.status(404).json({ error:'Correspondence not found' }); return false; } |     res.json(rows); | ||||||
|   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; |  | ||||||
|   } |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | // 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 |     const [[rfa]] = await sql.query("SELECT project_id FROM rfas WHERE id=?", [ | ||||||
| r.get('/maps/rfa/:rfa_id/drawings', requireAuth, async (req, res) => { |       rfa_id, | ||||||
|   const rows = await RfaDraw.findAll({ where: { rfa_id: Number(req.params.rfa_id) } }); |     ]); | ||||||
|   res.json(rows); |     if (!rfa) return res.status(404).json({ error: "RFA not found" }); | ||||||
| }); |     if ( | ||||||
| r.post('/maps/rfa/:rfa_id/drawings/:drawing_id', requireAuth, enrichPermissions(), requirePerm('rfa:update'), async (req, res) => { |       !req.principal.is_superadmin && | ||||||
|   if (!(await ensureRfaMembership(req, res))) return; |       !req.principal.inProject(rfa.project_id) | ||||||
|   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 }); |       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 }); |     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 | // REMOVE | ||||||
| r.get('/maps/correspondence/:corr_id/documents', requireAuth, async (req, res) => { | r.delete( | ||||||
|   const rows = await CorrDoc.findAll({ where: { correspondence_id: Number(req.params.corr_id) } }); |   "/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); |     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) }; | r.post( | ||||||
|   await CorrDoc.create({ correspondence_id: corr_id, document_id: doc_id }); |   "/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 }); |     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) }; | r.delete( | ||||||
|   const count = await CorrDoc.destroy({ where: { correspondence_id: corr_id, document_id: doc_id } }); |   "/maps/correspondence/:corr_id/documents/:doc_id", | ||||||
|   res.json({ ok: count > 0 }); |   requirePerm("corr.manage"), | ||||||
| }); |   async (req, res) => { | ||||||
|  |     const corr_id = Number(req.params.corr_id); | ||||||
|  |     const doc_id = Number(req.params.doc_id); | ||||||
|  |     const [[corr]] = await sql.query( | ||||||
|  |       "SELECT project_id FROM correspondences WHERE id=?", | ||||||
|  |       [corr_id] | ||||||
|  |     ); | ||||||
|  |     if (!corr) | ||||||
|  |       return res.status(404).json({ error: "Correspondence not found" }); | ||||||
|  |     if ( | ||||||
|  |       !req.principal.is_superadmin && | ||||||
|  |       !req.principal.inProject(corr.project_id) | ||||||
|  |     ) { | ||||||
|  |       return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|  |     } | ||||||
|  |     const [rs] = await sql.query( | ||||||
|  |       "DELETE FROM corr_document_map WHERE correspondence_id=? AND document_id=?", | ||||||
|  |       [corr_id, doc_id] | ||||||
|  |     ); | ||||||
|  |     res.json({ ok: rs.affectedRows > 0 }); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
|   | |||||||
| @@ -1,19 +1,96 @@ | |||||||
| import { Router } from 'express'; | // FILE: backend/src/routes/module_files.js | ||||||
| import { requireAuth } from '../middleware/auth.js'; | import { Router } from "express"; | ||||||
| import { sequelize } from '../db/sequelize.js'; | import sql from "../db/index.js"; | ||||||
| import FileModel from '../db/models/FileObject.js'; |  | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const Files = FileModel(sequelize); |  | ||||||
|  |  | ||||||
| async function listBy(mod, ref_id) { | // อ่านไฟล์ของแต่ละโมดูล โดยเช็ค ABAC + permission จาก principal | ||||||
|   return Files.findAll({ where: { module: mod, ref_id }, order:[['created_at','DESC']] }); | function readPermFor(mod) { | ||||||
|  |   switch (mod) { | ||||||
|  |     case "rfa": | ||||||
|  |       return "rfas.view"; | ||||||
|  |     case "correspondence": | ||||||
|  |       return "corr.view"; | ||||||
|  |     case "drawing": | ||||||
|  |       return "drawings.view"; | ||||||
|  |     case "document": | ||||||
|  |       return "documents.view"; | ||||||
|  |     case "transmittal": | ||||||
|  |       return "transmittals.manage"; // โมดูลนี้ seed เป็น manage | ||||||
|  |     default: | ||||||
|  |       return "documents.view"; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | async function projectOf(mod, id) { | ||||||
|  |   switch (mod) { | ||||||
|  |     case "rfa": { | ||||||
|  |       const [[row]] = await sql.query( | ||||||
|  |         "SELECT project_id FROM rfas WHERE id=?", | ||||||
|  |         [id] | ||||||
|  |       ); | ||||||
|  |       return row?.project_id || null; | ||||||
|  |     } | ||||||
|  |     case "correspondence": { | ||||||
|  |       const [[row]] = await sql.query( | ||||||
|  |         "SELECT project_id FROM correspondences WHERE id=?", | ||||||
|  |         [id] | ||||||
|  |       ); | ||||||
|  |       return row?.project_id || null; | ||||||
|  |     } | ||||||
|  |     case "drawing": { | ||||||
|  |       const [[row]] = await sql.query( | ||||||
|  |         "SELECT project_id FROM drawings WHERE id=?", | ||||||
|  |         [id] | ||||||
|  |       ); | ||||||
|  |       return row?.project_id || null; | ||||||
|  |     } | ||||||
|  |     case "document": { | ||||||
|  |       const [[row]] = await sql.query( | ||||||
|  |         "SELECT project_id FROM documents WHERE document_id=?", | ||||||
|  |         [id] | ||||||
|  |       ); | ||||||
|  |       return row?.project_id || null; | ||||||
|  |     } | ||||||
|  |     case "transmittal": { | ||||||
|  |       const [[row]] = await sql.query( | ||||||
|  |         "SELECT project_id FROM transmittals WHERE id=?", | ||||||
|  |         [id] | ||||||
|  |       ); | ||||||
|  |       return row?.project_id || null; | ||||||
|  |     } | ||||||
|  |     default: | ||||||
|  |       return null; | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| for (const mod of ['rfa','correspondence','drawing','document','transmittal']) { | // /:module(s)/:id/files | ||||||
|   r.get(`/${mod}s/:id/files`, requireAuth, async (req, res) => { | for (const mod of [ | ||||||
|     const items = await listBy(mod, Number(req.params.id)); |   "rfa", | ||||||
|     res.json(items); |   "correspondence", | ||||||
|  |   "drawing", | ||||||
|  |   "document", | ||||||
|  |   "transmittal", | ||||||
|  | ]) { | ||||||
|  |   r.get(`/${mod}s/:id/files`, async (req, res) => { | ||||||
|  |     const ref_id = Number(req.params.id); | ||||||
|  |     const p = req.principal; | ||||||
|  |     if (!p) return res.status(401).json({ error: "Unauthenticated" }); | ||||||
|  |  | ||||||
|  |     const need = readPermFor(mod); | ||||||
|  |     if (!(p.is_superadmin || p.permissions?.has?.(need))) { | ||||||
|  |       return res.status(403).json({ error: "FORBIDDEN", need }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const pid = await projectOf(mod, ref_id); | ||||||
|  |     if (!p.is_superadmin && (!pid || !p.inProject(pid))) { | ||||||
|  |       return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const [rows] = await sql.query( | ||||||
|  |       `SELECT f.* FROM files f WHERE f.module=? AND f.ref_id=? ORDER BY f.file_id DESC`, | ||||||
|  |       [mod, ref_id] | ||||||
|  |     ); | ||||||
|  |     res.json(rows); | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,66 +1,115 @@ | |||||||
| // src/routes/map.js | // FILE: backend/src/routes/mvp.js | ||||||
| import { Router } from 'express'; | // (generic entity maps — ใช้ ‘projects.view’ อ่าน และ ‘projects.manage’ เขียน/ลบ) | ||||||
| import sql from '../db/index.js'; | import { Router } from "express"; | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | import sql from "../db/index.js"; | ||||||
| import { buildScopeWhere, ownerResolvers } from '../utils/scope.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
| import PERM from '../config/permissions.js'; |  | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const OWN = ownerResolvers(sql, 'entity_maps', 'id'); |  | ||||||
|  |  | ||||||
| // LIST | // LIST — projects.view (ORG scope) | ||||||
| r.get('/', | r.get( | ||||||
|   requirePerm(PERM.map.read, { scope: 'global' }), |   "/", | ||||||
|  |   requirePerm("projects.view", { orgParam: "org_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const { project_id, org_id, module, src_type, dst_type, limit = 100, offset = 0 } = req.query; |     const { | ||||||
|  |       project_id, | ||||||
|  |       org_id, | ||||||
|  |       module, | ||||||
|  |       src_type, | ||||||
|  |       dst_type, | ||||||
|  |       limit = 100, | ||||||
|  |       offset = 0, | ||||||
|  |     } = req.query; | ||||||
|  |     const p = req.principal; | ||||||
|  |     const params = []; | ||||||
|  |     const cond = []; | ||||||
|  |  | ||||||
|     const base = buildScopeWhere(req.principal, { |     if (!p.is_superadmin) { | ||||||
|       tableAlias: 'm', |       if (org_id) { | ||||||
|       orgColumn: 'm.org_id', |         if (!p.inOrg(Number(org_id))) | ||||||
|       projectColumn: 'm.project_id', |           return res.status(403).json({ error: "FORBIDDEN_ORG" }); | ||||||
|       permCode: PERM.map.read, |         cond.push("m.org_id=?"); | ||||||
|       preferProject: true, |         params.push(Number(org_id)); | ||||||
|     }); |       } else if (p.org_ids?.length) { | ||||||
|  |         cond.push(`m.org_id IN (${p.org_ids.map(() => "?").join(",")})`); | ||||||
|  |         params.push(...p.org_ids); | ||||||
|  |       } | ||||||
|  |     } else if (org_id) { | ||||||
|  |       cond.push("m.org_id=?"); | ||||||
|  |       params.push(Number(org_id)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     const extra = []; |     if (project_id) { | ||||||
|     const params = { ...base.params, limit: Number(limit), offset: Number(offset) }; |       cond.push("m.project_id=?"); | ||||||
|     if (project_id) { extra.push('m.project_id = :project_id'); params.project_id = Number(project_id); } |       params.push(Number(project_id)); | ||||||
|     if (org_id)     { extra.push('m.org_id = :org_id');         params.org_id = Number(org_id); } |     } | ||||||
|     if (module)     { extra.push('m.module = :module');         params.module = module; } |     if (module) { | ||||||
|     if (src_type)   { extra.push('m.src_type = :src_type');     params.src_type = src_type; } |       cond.push("m.module=?"); | ||||||
|     if (dst_type)   { extra.push('m.dst_type = :dst_type');     params.dst_type = dst_type; } |       params.push(module); | ||||||
|  |     } | ||||||
|  |     if (src_type) { | ||||||
|  |       cond.push("m.src_type=?"); | ||||||
|  |       params.push(src_type); | ||||||
|  |     } | ||||||
|  |     if (dst_type) { | ||||||
|  |       cond.push("m.dst_type=?"); | ||||||
|  |       params.push(dst_type); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     const where = [base.where, ...extra].filter(Boolean).join(' AND '); |     const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; | ||||||
|     const [rows] = await sql.query( |     const [rows] = await sql.query( | ||||||
|       `SELECT m.* FROM entity_maps m |       `SELECT m.* FROM entity_maps m ${where} ORDER BY m.id DESC LIMIT ? OFFSET ?`, | ||||||
|        WHERE ${where} |       [...params, Number(limit), Number(offset)] | ||||||
|        ORDER BY m.id DESC LIMIT :limit OFFSET :offset`, |  | ||||||
|       params |  | ||||||
|     ); |     ); | ||||||
|     res.json(rows); |     res.json(rows); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| // CREATE | // CREATE — projects.manage (ORG scope) | ||||||
| r.post('/', | r.post( | ||||||
|   requirePerm(PERM.map.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }), |   "/", | ||||||
|  |   requirePerm("projects.manage", { orgParam: "org_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const { org_id, project_id, module, src_type, src_id, dst_type, dst_id, remark } = req.body; |     const { | ||||||
|  |       org_id, | ||||||
|  |       project_id, | ||||||
|  |       module, | ||||||
|  |       src_type, | ||||||
|  |       src_id, | ||||||
|  |       dst_type, | ||||||
|  |       dst_id, | ||||||
|  |       remark, | ||||||
|  |     } = req.body || {}; | ||||||
|  |     if (!org_id || !project_id || !module) | ||||||
|  |       return res | ||||||
|  |         .status(400) | ||||||
|  |         .json({ error: "org_id, project_id, module required" }); | ||||||
|     const [rs] = await sql.query( |     const [rs] = await sql.query( | ||||||
|       `INSERT INTO entity_maps (org_id, project_id, module, src_type, src_id, dst_type, dst_id, remark, created_by) |       `INSERT INTO entity_maps (org_id, project_id, module, src_type, src_id, dst_type, dst_id, remark, created_by) | ||||||
|        VALUES (?,?,?,?,?,?,?,?,?)`, |        VALUES (?,?,?,?,?,?,?,?,?)`, | ||||||
|       [org_id, project_id, module, src_type, Number(src_id), dst_type, Number(dst_id), remark ?? null, req.principal.userId] |       [ | ||||||
|  |         Number(org_id), | ||||||
|  |         Number(project_id), | ||||||
|  |         module, | ||||||
|  |         src_type || null, | ||||||
|  |         src_id ? Number(src_id) : null, | ||||||
|  |         dst_type || null, | ||||||
|  |         dst_id ? Number(dst_id) : null, | ||||||
|  |         remark || null, | ||||||
|  |         req.principal.user_id, | ||||||
|  |       ] | ||||||
|     ); |     ); | ||||||
|     res.json({ id: rs.insertId }); |     res.json({ id: rs.insertId }); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| // DELETE (by id) | // DELETE — projects.manage (ORG scope) | ||||||
| r.delete('/:id', | r.delete( | ||||||
|   requirePerm(PERM.map.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }), |   "/:id", | ||||||
|  |   requirePerm("projects.manage", { orgParam: "org_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const id = Number(req.params.id); |     const id = Number(req.params.id); | ||||||
|     await sql.query('DELETE FROM entity_maps WHERE id=?', [id]); |     await sql.query("DELETE FROM entity_maps WHERE id=?", [id]); | ||||||
|     res.json({ ok: 1 }); |     res.json({ ok: 1 }); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|   | |||||||
| @@ -1,28 +1,29 @@ | |||||||
| import { Router } from 'express'; | // FILE: backend/src/routes/ops.js | ||||||
| import { sequelize } from '../db/sequelize.js'; | import { Router } from "express"; | ||||||
| import fs from 'fs'; | import sql from "../db/index.js"; | ||||||
| import path from 'path'; | import fs from "node:fs"; | ||||||
|  | import path from "node:path"; | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
|  |  | ||||||
| r.get('/ready', async (_req, res) => { | r.get("/ready", async (_req, res) => { | ||||||
|   try { |   try { | ||||||
|     await sequelize.query('SELECT 1'); |     await sql.query("SELECT 1"); | ||||||
|     return res.json({ ready: true }); |     res.json({ ready: true }); | ||||||
|   } catch { |   } catch { | ||||||
|     return res.status(500).json({ ready: false }); |     res.status(500).json({ ready: false }); | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
|  |  | ||||||
| r.get('/live', (_req, res) => res.json({ live: true })); | r.get("/live", (_req, res) => res.json({ live: true })); | ||||||
|  |  | ||||||
| r.get('/version', (_req, res) => { | r.get("/version", (_req, res) => { | ||||||
|   try { |   try { | ||||||
|     const pkgPath = path.resolve(process.cwd(), 'package.json'); |     const pkgPath = path.resolve(process.cwd(), "package.json"); | ||||||
|     const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); |     const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); | ||||||
|     res.json({ name: pkg.name, version: pkg.version }); |     res.json({ name: pkg.name, version: pkg.version }); | ||||||
|   } catch { |   } catch { | ||||||
|     res.json({ name: 'dms-backend', version: 'unknown' }); |     res.json({ name: "dms-backend", version: "unknown" }); | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,41 +1,52 @@ | |||||||
| import { Router } from 'express'; | // FILE: backend/src/routes/organizations.js | ||||||
| import sql from '../db/index.js'; | import { Router } from "express"; | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | import sql from "../db/index.js"; | ||||||
| import { buildScopeWhere } from '../utils/scope.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
| import PERM from '../config/permissions.js'; |  | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
|  |  | ||||||
| // LIST (org) – ดูเฉพาะ org ใน scope | // LIST | ||||||
| r.get('/', | r.get("/", requirePerm("organizations.view"), async (_req, res) => { | ||||||
|   requirePerm('organization.read', { scope: 'global' }), |  | ||||||
|   async (req, res) => { |  | ||||||
|     const { where, params } = buildScopeWhere(req.principal, { |  | ||||||
|       tableAlias: 'o', orgColumn: 'o.org_id', projectColumn: 'NULL', |  | ||||||
|       permCode: 'organization.read', |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|   const [rows] = await sql.query( |   const [rows] = await sql.query( | ||||||
|       `SELECT o.* FROM organizations o WHERE ${where}`, |     "SELECT * FROM organizations ORDER BY org_name" | ||||||
|       params |  | ||||||
|   ); |   ); | ||||||
|   res.json(rows); |   res.json(rows); | ||||||
|   } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
| // GET by id | // GET | ||||||
| r.get('/:id', | r.get("/:id", requirePerm("organizations.view"), async (req, res) => { | ||||||
|   requirePerm('organization.read', { |  | ||||||
|     scope: 'org', |  | ||||||
|     getOrgId: async req => Number(req.params.id), |  | ||||||
|   }), |  | ||||||
|   async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   const id = Number(req.params.id); | ||||||
|     const [[row]] = await sql.query('SELECT * FROM organizations WHERE org_id=?', [id]); |   const [[row]] = await sql.query( | ||||||
|     if (!row) return res.status(404).json({ error: 'Not found' }); |     "SELECT * FROM organizations WHERE org_id=?", | ||||||
|     res.json(row); |     [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; | export default r; | ||||||
|   | |||||||
| @@ -1,16 +1,16 @@ | |||||||
| import { Router } from 'express'; | // FILE: backend/src/routes/permissions.js | ||||||
| import sql from '../db/index.js'; | import { Router } from "express"; | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | import sql from "../db/index.js"; | ||||||
| import PERM from '../config/permissions.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
|  |  | ||||||
| r.get('/', | // GLOBAL: settings.manage จึงเห็นได้ทั้งหมด | ||||||
|   requirePerm('permission.read', { scope: 'global' }), | r.get("/", requirePerm("settings.manage"), async (_req, res) => { | ||||||
|   async (req, res) => { |   const [rows] = await sql.query( | ||||||
|     const [rows] = await sql.query('SELECT permission_id, permission_code, description FROM permissions ORDER BY permission_code'); |     "SELECT permission_id, perm_code AS permission_code, scope_level, description FROM permissions ORDER BY perm_code" | ||||||
|     res.json(rows); |  | ||||||
|   } |  | ||||||
|   ); |   ); | ||||||
|  |   res.json(rows); | ||||||
|  | }); | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
|   | |||||||
| @@ -1,80 +1,122 @@ | |||||||
| import { Router } from 'express'; | // FILE: backend/src/routes/projects.js | ||||||
| import sql from '../db/index.js'; |  | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | import { Router } from "express"; | ||||||
| import { buildScopeWhere } from '../utils/scope.js'; | import sql from "../db/index.js"; | ||||||
| import PERM from '../config/permissions.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
|  |  | ||||||
| // LIST – จำกัดตาม org/project scope ของผู้ใช้ | // LIST — ORG scope | ||||||
| r.get('/', | r.get( | ||||||
|   requirePerm('project.read', { scope: 'global' }), |   "/", | ||||||
|  |   requirePerm("projects.view", { orgParam: "org_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const { where, params } = buildScopeWhere(req.principal, { |     const p = req.principal; | ||||||
|       tableAlias: 'p', orgColumn: 'p.org_id', projectColumn: 'p.project_id', |     const { org_id } = req.query; | ||||||
|       permCode: 'project.read', preferProject: true, |     const params = []; | ||||||
|     }); |     const cond = []; | ||||||
|  |  | ||||||
|  |     if (!p.is_superadmin) { | ||||||
|  |       if (org_id) { | ||||||
|  |         if (!p.inOrg(Number(org_id))) | ||||||
|  |           return res.status(403).json({ error: "FORBIDDEN_ORG" }); | ||||||
|  |         cond.push("p.org_id=?"); | ||||||
|  |         params.push(Number(org_id)); | ||||||
|  |       } else if (p.org_ids?.length) { | ||||||
|  |         cond.push(`p.org_id IN (${p.org_ids.map(() => "?").join(",")})`); | ||||||
|  |         params.push(...p.org_ids); | ||||||
|  |       } | ||||||
|  |     } else if (org_id) { | ||||||
|  |       cond.push("p.org_id=?"); | ||||||
|  |       params.push(Number(org_id)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; | ||||||
|     const [rows] = await sql.query( |     const [rows] = await sql.query( | ||||||
|       `SELECT p.* FROM projects p WHERE ${where}`, |       `SELECT p.* FROM projects p ${where} ORDER BY p.project_name`, | ||||||
|       params |       params | ||||||
|     ); |     ); | ||||||
|     res.json(rows); |     res.json(rows); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| // GET | // GET — PROJECT scope | ||||||
| r.get('/:id', | r.get( | ||||||
|   requirePerm('project.read', { |   "/:id", | ||||||
|     scope: 'project', |   requirePerm("projects.view", { orgParam: "org_id" }), | ||||||
|     getProjectId: async req => Number(req.params.id), |  | ||||||
|   }), |  | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const id = Number(req.params.id); |     const id = Number(req.params.id); | ||||||
|     const [[row]] = await sql.query('SELECT * FROM projects WHERE project_id=?', [id]); |     const [[row]] = await sql.query( | ||||||
|     if (!row) return res.status(404).json({ error: 'Not found' }); |       "SELECT * FROM projects WHERE project_id=?", | ||||||
|  |       [id] | ||||||
|  |     ); | ||||||
|  |     if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|  |     const p = req.principal; | ||||||
|  |     if (!p.is_superadmin && !p.inOrg(row.org_id)) | ||||||
|  |       return res.status(403).json({ error: "FORBIDDEN_ORG" }); | ||||||
|     res.json(row); |     res.json(row); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| // CREATE | // CREATE — ORG scope | ||||||
| r.post('/', | r.post( | ||||||
|   requirePerm('project.create', { |   "/", | ||||||
|     scope: 'org', |   requirePerm("projects.manage", { orgParam: "org_id" }), | ||||||
|     getOrgId: async req => req.body?.org_id ?? null, |  | ||||||
|   }), |  | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const { org_id, project_code, project_name } = req.body; |     const { org_id, project_code, project_name } = req.body || {}; | ||||||
|  |     if (!org_id || !project_code || !project_name) { | ||||||
|  |       return res | ||||||
|  |         .status(400) | ||||||
|  |         .json({ error: "org_id, project_code, project_name required" }); | ||||||
|  |     } | ||||||
|     const [rs] = await sql.query( |     const [rs] = await sql.query( | ||||||
|       'INSERT INTO projects (org_id, project_code, project_name) VALUES (?,?,?)', |       "INSERT INTO projects (org_id, project_code, project_name, created_by) VALUES (?,?,?,?)", | ||||||
|       [org_id, project_code, project_name] |       [Number(org_id), project_code, project_name, req.principal.user_id] | ||||||
|     ); |     ); | ||||||
|     res.json({ project_id: rs.insertId }); |     res.status(201).json({ project_id: rs.insertId }); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| // UPDATE | // UPDATE — ORG scope | ||||||
| r.put('/:id', | r.put( | ||||||
|   requirePerm('project.update', { |   "/:id", | ||||||
|     scope: 'project', |   requirePerm("projects.manage", { orgParam: "org_id" }), | ||||||
|     getProjectId: async req => Number(req.params.id), |  | ||||||
|   }), |  | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const { project_name } = req.body; |  | ||||||
|     const id = Number(req.params.id); |     const id = Number(req.params.id); | ||||||
|     await sql.query('UPDATE projects SET project_name=? WHERE project_id=?', [project_name, id]); |     const [[row]] = await sql.query( | ||||||
|  |       "SELECT * FROM projects WHERE project_id=?", | ||||||
|  |       [id] | ||||||
|  |     ); | ||||||
|  |     if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|  |     const p = req.principal; | ||||||
|  |     if (!p.is_superadmin && !p.inOrg(row.org_id)) | ||||||
|  |       return res.status(403).json({ error: "FORBIDDEN_ORG" }); | ||||||
|  |  | ||||||
|  |     const { project_name } = req.body || {}; | ||||||
|  |     await sql.query( | ||||||
|  |       "UPDATE projects SET project_name=?, updated_by=? WHERE project_id=?", | ||||||
|  |       [project_name ?? row.project_name, req.principal.user_id, id] | ||||||
|  |     ); | ||||||
|     res.json({ ok: 1 }); |     res.json({ ok: 1 }); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| // DELETE | // DELETE — ORG scope | ||||||
| r.delete('/:id', | r.delete( | ||||||
|   requirePerm('project.delete', { |   "/:id", | ||||||
|     scope: 'project', |   requirePerm("projects.manage", { orgParam: "org_id" }), | ||||||
|     getProjectId: async req => Number(req.params.id), |  | ||||||
|   }), |  | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const id = Number(req.params.id); |     const id = Number(req.params.id); | ||||||
|     await sql.query('DELETE FROM projects WHERE project_id=?', [id]); |     const [[row]] = await sql.query( | ||||||
|  |       "SELECT * FROM projects WHERE project_id=?", | ||||||
|  |       [id] | ||||||
|  |     ); | ||||||
|  |     if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|  |     const p = req.principal; | ||||||
|  |     if (!p.is_superadmin && !p.inOrg(row.org_id)) | ||||||
|  |       return res.status(403).json({ error: "FORBIDDEN_ORG" }); | ||||||
|  |  | ||||||
|  |     await sql.query("DELETE FROM projects WHERE project_id=?", [id]); | ||||||
|     res.json({ ok: 1 }); |     res.json({ ok: 1 }); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|   | |||||||
							
								
								
									
										126
									
								
								backend/src/routes/rbac_admin copy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								backend/src/routes/rbac_admin copy.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,126 @@ | |||||||
|  | // FILE: backend/src/routes/rbac_admin.js | ||||||
|  | // RBAC admin — ใช้ settings.manage ทั้งหมด | ||||||
|  | import { Router } from "express"; | ||||||
|  | import sql from "../db/index.js"; | ||||||
|  | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
|  |  | ||||||
|  | const r = Router(); | ||||||
|  |  | ||||||
|  | // ROLES | ||||||
|  | r.get("/roles", requirePerm("settings.manage"), async (_req, res) => { | ||||||
|  |   const [rows] = await sql.query( | ||||||
|  |     "SELECT role_id, role_code, role_name, description FROM roles ORDER BY role_code" | ||||||
|  |   ); | ||||||
|  |   res.json(rows); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // PERMISSIONS | ||||||
|  | r.get("/permissions", requirePerm("settings.manage"), async (_req, res) => { | ||||||
|  |   const [rows] = await sql.query( | ||||||
|  |     "SELECT permission_id, perm_code AS permission_code, scope_level, description FROM permissions ORDER BY perm_code" | ||||||
|  |   ); | ||||||
|  |   res.json(rows); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // role -> permissions | ||||||
|  | r.get( | ||||||
|  |   "/roles/:role_id/permissions", | ||||||
|  |   requirePerm("settings.manage"), | ||||||
|  |   async (req, res) => { | ||||||
|  |     const role_id = Number(req.params.role_id); | ||||||
|  |     const [rows] = await sql.query( | ||||||
|  |       `SELECT p.permission_id, p.perm_code AS permission_code, p.description | ||||||
|  |      FROM role_permissions rp | ||||||
|  |      JOIN permissions p ON p.permission_id = rp.permission_id | ||||||
|  |      WHERE rp.role_id=? ORDER BY p.perm_code`, | ||||||
|  |       [role_id] | ||||||
|  |     ); | ||||||
|  |     res.json(rows); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | r.post( | ||||||
|  |   "/roles/:role_id/permissions", | ||||||
|  |   requirePerm("settings.manage"), | ||||||
|  |   async (req, res) => { | ||||||
|  |     const role_id = Number(req.params.role_id); | ||||||
|  |     const { permission_id } = req.body || {}; | ||||||
|  |     await sql.query( | ||||||
|  |       "INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?,?)", | ||||||
|  |       [role_id, Number(permission_id)] | ||||||
|  |     ); | ||||||
|  |     res.json({ ok: 1 }); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | r.delete( | ||||||
|  |   "/roles/:role_id/permissions/:permission_id", | ||||||
|  |   requirePerm("settings.manage"), | ||||||
|  |   async (req, res) => { | ||||||
|  |     const role_id = Number(req.params.role_id); | ||||||
|  |     const permission_id = Number(req.params.permission_id); | ||||||
|  |     await sql.query( | ||||||
|  |       "DELETE FROM role_permissions WHERE role_id=? AND permission_id=?", | ||||||
|  |       [role_id, permission_id] | ||||||
|  |     ); | ||||||
|  |     res.json({ ok: 1 }); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | // user -> roles (global/org/project scope columns มีในตาราง user_roles ตามสคีมา) | ||||||
|  | r.get( | ||||||
|  |   "/users/:user_id/roles", | ||||||
|  |   requirePerm("settings.manage"), | ||||||
|  |   async (req, res) => { | ||||||
|  |     const user_id = Number(req.params.user_id); | ||||||
|  |     const [rows] = await sql.query( | ||||||
|  |       `SELECT ur.user_id, ur.role_id, r.role_code, r.role_name, ur.org_id, ur.project_id | ||||||
|  |      FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id | ||||||
|  |      WHERE ur.user_id=? ORDER BY r.role_code`, | ||||||
|  |       [user_id] | ||||||
|  |     ); | ||||||
|  |     res.json(rows); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | r.post( | ||||||
|  |   "/users/:user_id/roles", | ||||||
|  |   requirePerm("settings.manage"), | ||||||
|  |   async (req, res) => { | ||||||
|  |     const user_id = Number(req.params.user_id); | ||||||
|  |     const { role_id, org_id = null, project_id = null } = req.body || {}; | ||||||
|  |     await sql.query( | ||||||
|  |       "INSERT INTO user_roles (user_id, role_id, org_id, project_id) VALUES (?,?,?,?)", | ||||||
|  |       [ | ||||||
|  |         user_id, | ||||||
|  |         Number(role_id), | ||||||
|  |         org_id ? Number(org_id) : null, | ||||||
|  |         project_id ? Number(project_id) : null, | ||||||
|  |       ] | ||||||
|  |     ); | ||||||
|  |     res.json({ ok: 1 }); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | r.delete( | ||||||
|  |   "/users/:user_id/roles", | ||||||
|  |   requirePerm("settings.manage"), | ||||||
|  |   async (req, res) => { | ||||||
|  |     const user_id = Number(req.params.user_id); | ||||||
|  |     const { role_id, org_id = null, project_id = null } = req.body || {}; | ||||||
|  |     // สร้างเงื่อนไขแบบ dynamic สำหรับ NULL-safe compare | ||||||
|  |     const whereOrg = org_id === null ? "ur.org_id IS NULL" : "ur.org_id = ?"; | ||||||
|  |     const wherePrj = | ||||||
|  |       project_id === null ? "ur.project_id IS NULL" : "ur.project_id = ?"; | ||||||
|  |     const params = [user_id, Number(role_id)]; | ||||||
|  |     if (org_id !== null) params.push(Number(org_id)); | ||||||
|  |     if (project_id !== null) params.push(Number(project_id)); | ||||||
|  |     await sql.query( | ||||||
|  |       `DELETE FROM user_roles ur WHERE ur.user_id=? AND ur.role_id=? AND ${whereOrg} AND ${wherePrj}`, | ||||||
|  |       params | ||||||
|  |     ); | ||||||
|  |     res.json({ ok: 1 }); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | export default r; | ||||||
							
								
								
									
										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) | // FILE: backend/src/routes/rbac_admin.js | ||||||
| import { Router } from 'express'; | import { Router } from "express"; | ||||||
| import sql from '../db/index.js'; | import { Role, Permission, UserProjectRole, Project } from "../db/sequelize.js"; | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | import { authJwt } from "../middleware/authJwt.js"; | ||||||
| import PERM from '../config/permissions.js'; | import { loadPrincipalMw } from "../middleware/loadPrincipal.js"; // แก้ไข: import ให้ถูกต้อง | ||||||
|  | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
|  |  | ||||||
| const r = Router(); | const router = Router(); | ||||||
|  |  | ||||||
| /** LIST: roles */ | // Middleware Chain ที่ถูกต้อง 100% ตามสถาปัตยกรรมของคุณ | ||||||
| r.get('/roles', | router.use(authJwt(), loadPrincipalMw()); | ||||||
|   requirePerm(PERM.rbac_admin.read, { scope: 'global' }), |  | ||||||
|   async (req, res) => { | // == ROLES Management == | ||||||
|     const [rows] = await sql.query('SELECT role_id, role_code, role_name, description FROM roles ORDER BY role_code'); | router.get("/roles", requirePerm("roles.manage"), async (req, res, next) => { | ||||||
|     res.json(rows); |   try { | ||||||
|  |     const roles = await Role.findAll({ | ||||||
|  |       include: [{ model: Permission, attributes: ["id", "name"], through: { attributes: [] } }], | ||||||
|  |       order: [["name", "ASC"]], | ||||||
|  |     }); | ||||||
|  |     res.json(roles); | ||||||
|  |   } catch (error) { next(error); } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | router.post("/roles", requirePerm("roles.manage"), async (req, res, next) => { | ||||||
|  |   const { name, description } = req.body; | ||||||
|  |   if (!name) return res.status(400).json({ message: "Role name is required." }); | ||||||
|  |   try { | ||||||
|  |     const newRole = await Role.create({ name, description }); | ||||||
|  |     res.status(201).json(newRole); | ||||||
|  |   } catch (error) { | ||||||
|  |     if (error.name === "SequelizeUniqueConstraintError") { | ||||||
|  |       return res.status(409).json({ message: `Role '${name}' already exists.` }); | ||||||
|     } |     } | ||||||
| ); |     next(error); | ||||||
|  |  | ||||||
| /** LIST: permissions */ |  | ||||||
| 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); |  | ||||||
|   } |   } | ||||||
| ); | }); | ||||||
|  |  | ||||||
| /** LIST: role→permissions */ | router.put("/roles/:id/permissions", requirePerm("roles.manage"), async (req, res, next) => { | ||||||
| r.get('/roles/:role_id/permissions', |     const { permissionIds } = req.body; | ||||||
|   requirePerm(PERM.rbac_admin.read, { scope: 'global' }), |     if (!Array.isArray(permissionIds)) return res.status(400).json({ message: "permissionIds must be an array." }); | ||||||
|   async (req, res) => { |     try { | ||||||
|     const role_id = Number(req.params.role_id); |         const role = await Role.findByPk(req.params.id); | ||||||
|     const [rows] = await sql.query( |         if (!role) return res.status(404).json({ message: "Role not found." }); | ||||||
|       `SELECT p.permission_id, p.permission_code, p.description |         await role.setPermissions(permissionIds); | ||||||
|        FROM role_permissions rp |         const updatedRole = await Role.findByPk(req.params.id, { | ||||||
|        JOIN permissions p ON p.permission_id = rp.permission_id |             include: [{ model: Permission, attributes: ['id', 'name'], through: { attributes: [] } }] | ||||||
|        WHERE rp.role_id=? ORDER BY p.permission_code`, [role_id]); |         }); | ||||||
|     res.json(rows); |         res.json(updatedRole); | ||||||
|   } |     } catch (error) { next(error); } | ||||||
| ); | }); | ||||||
|  |  | ||||||
| /** MAP: role↔permission (grant/revoke) */ | // == USER-PROJECT-ROLES Management == | ||||||
| r.post('/roles/:role_id/permissions', | router.get("/user-project-roles", requirePerm("users.manage"), async (req, res, next) => { | ||||||
|   requirePerm(PERM.rbac_admin.grant_perm, { scope: 'global' }), |   const { userId } = req.query; | ||||||
|   async (req, res) => { |   if (!userId) return res.status(400).json({ message: "userId query parameter is required." }); | ||||||
|     const role_id = Number(req.params.role_id); |   try { | ||||||
|     const { permission_id } = req.body || {}; |     const assignments = await UserProjectRole.findAll({ | ||||||
|     await sql.query('INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?,?)', |       where: { user_id: userId }, | ||||||
|       [role_id, Number(permission_id)]); |       include: [ { model: Project, attributes: ["id", "name"] }, { model: Role, attributes: ["id", "name"] } ], | ||||||
|     res.json({ ok: 1 }); |     }); | ||||||
|   } |     res.json(assignments); | ||||||
| ); |   } catch (error) { next(error); } | ||||||
|  | }); | ||||||
|  |  | ||||||
| r.delete('/roles/:role_id/permissions/:permission_id', | router.post("/user-project-roles", requirePerm("users.manage"), async (req, res, next) => { | ||||||
|   requirePerm(PERM.rbac_admin.grant_perm, { scope: 'global' }), |   const { userId, projectId, roleId } = req.body; | ||||||
|   async (req, res) => { |   if (!userId || !projectId || !roleId) return res.status(400).json({ message: "userId, projectId, and roleId are required." }); | ||||||
|     const role_id = Number(req.params.role_id); |   try { | ||||||
|     const permission_id = Number(req.params.permission_id); |     const [assignment, created] = await UserProjectRole.findOrCreate({ | ||||||
|     await sql.query('DELETE FROM role_permissions WHERE role_id=? AND permission_id=?', [role_id, permission_id]); |       where: { user_id: userId, project_id: projectId, role_id: roleId }, | ||||||
|     res.json({ ok: 1 }); |       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) */ | router.delete("/user-project-roles", requirePerm("users.manage"), async (req, res, next) => { | ||||||
| r.get('/users/:user_id/roles', |   const { userId, projectId, roleId } = req.body; | ||||||
|   requirePerm(PERM.rbac_admin.read, { scope: 'global' }), |   if (!userId || !projectId || !roleId) return res.status(400).json({ message: "userId, projectId, and roleId are required." }); | ||||||
|   async (req, res) => { |   try { | ||||||
|     const user_id = Number(req.params.user_id); |     const deletedCount = await UserProjectRole.destroy({ where: { user_id: userId, project_id: projectId, role_id: roleId } }); | ||||||
|     const [rows] = await sql.query( |     if (deletedCount === 0) return res.status(404).json({ message: 'Assignment not found.' }); | ||||||
|       `SELECT ur.user_id, ur.role_id, r.role_code, r.role_name, ur.org_id, ur.project_id |     res.status(204).send(); | ||||||
|        FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id |   } catch (error) { next(error); } | ||||||
|        WHERE ur.user_id=? ORDER BY r.role_code`, [user_id]); | }); | ||||||
|     res.json(rows); |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| /** MAP: user↔role(+scope)  (assign / revoke) */ | export default router; | ||||||
| r.post('/users/:user_id/roles', |  | ||||||
|   requirePerm(PERM.rbac_admin.assign_role, { scope: 'global' }), |  | ||||||
|   async (req, res) => { |  | ||||||
|     const user_id = Number(req.params.user_id); |  | ||||||
|     const { role_id, org_id = null, project_id = null } = req.body || {}; |  | ||||||
|     await sql.query( |  | ||||||
|       'INSERT INTO user_roles (user_id, role_id, org_id, project_id) VALUES (?,?,?,?)', |  | ||||||
|       [user_id, Number(role_id), org_id ? Number(org_id) : null, project_id ? Number(project_id) : null] |  | ||||||
|     ); |  | ||||||
|     res.json({ ok: 1 }); |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| r.delete('/users/:user_id/roles', |  | ||||||
|   requirePerm(PERM.rbac_admin.assign_role, { scope: 'global' }), |  | ||||||
|   async (req, res) => { |  | ||||||
|     const user_id = Number(req.params.user_id); |  | ||||||
|     const { role_id, org_id = null, project_id = null } = req.body || {}; |  | ||||||
|     await sql.query( |  | ||||||
|       'DELETE FROM user_roles WHERE user_id=? AND role_id=? AND <=> org_id ? AND <=> project_id ?' |  | ||||||
|       .replace('<=> org_id ?', (org_id === null ? 'org_id IS ?' : 'org_id=?')) |  | ||||||
|       .replace('<=> project_id ?', (project_id === null ? 'project_id IS ?' : 'project_id=?')), |  | ||||||
|       [user_id, Number(role_id), org_id, project_id] |  | ||||||
|     ); |  | ||||||
|     res.json({ ok: 1 }); |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| export default r; |  | ||||||
| @@ -1,35 +1,91 @@ | |||||||
| import { Router } from 'express'; | // FILE: backend/src/routes/rfa.js | ||||||
| import { requireAuth } from '../middleware/auth.js'; | // RFA: create + update-status ผ่าน stored procedures | ||||||
| import { requirePermission } from '../middleware/perm.js'; | import { Router } from "express"; | ||||||
| import { callProc } from '../db/index.js'; | import sql, { callProc } from "../db/index.js"; | ||||||
|  | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
|  |  | ||||||
| const router = Router(); | const r = Router(); | ||||||
|  |  | ||||||
| router.post('/create', | // CREATE (PROJECT scope) -> rfas.create | ||||||
|   requireAuth, | r.post( | ||||||
|   requirePermission(['RFA_CREATE'], { projectRequired: true }), |   "/create", | ||||||
|  |   requirePerm("rfas.create", { projectParam: "project_id" }), | ||||||
|   async (req, res, next) => { |   async (req, res, next) => { | ||||||
|     try { |     try { | ||||||
|       const { project_id, cor_status_id, cor_no, title, originator_id, recipient_id, keywords = null, pdf_path = null, item_doc_ids = [] } = req.body || {}; |       const { | ||||||
|       const json = JSON.stringify(item_doc_ids.map(Number)); |         project_id, | ||||||
|       await callProc('sp_rfa_create_with_items', [ |         cor_status_id, | ||||||
|         req.user.user_id, project_id, cor_status_id, cor_no, title, originator_id, recipient_id, keywords, pdf_path, json, null |         cor_no, | ||||||
|  |         title, | ||||||
|  |         originator_id, | ||||||
|  |         recipient_id, | ||||||
|  |         keywords = null, | ||||||
|  |         pdf_path = null, | ||||||
|  |         item_doc_ids = [], | ||||||
|  |       } = req.body || {}; | ||||||
|  |  | ||||||
|  |       if (!project_id || !title) { | ||||||
|  |         return res.status(400).json({ error: "project_id and title required" }); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       const json = JSON.stringify((item_doc_ids || []).map(Number)); | ||||||
|  |       await callProc("sp_rfa_create_with_items", [ | ||||||
|  |         req.principal.user_id, | ||||||
|  |         project_id, | ||||||
|  |         cor_status_id ?? null, | ||||||
|  |         cor_no ?? null, | ||||||
|  |         title, | ||||||
|  |         originator_id ?? null, | ||||||
|  |         recipient_id ?? null, | ||||||
|  |         keywords, | ||||||
|  |         pdf_path, | ||||||
|  |         json, | ||||||
|  |         null, | ||||||
|       ]); |       ]); | ||||||
|  |  | ||||||
|       res.status(201).json({ ok: true }); |       res.status(201).json({ ok: true }); | ||||||
|     } catch (e) { next(e); } |     } catch (e) { | ||||||
|  |       next(e); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| router.post('/update-status', | // UPDATE STATUS (PROJECT scope) -> rfas.respond | ||||||
|   requireAuth, | r.post( | ||||||
|   requirePermission(['RFA_STATUS_UPDATE'], { projectRequired: true }), |   "/update-status", | ||||||
|  |   requirePerm("rfas.respond"), | ||||||
|   async (req, res, next) => { |   async (req, res, next) => { | ||||||
|     try { |     try { | ||||||
|       const { rfa_corr_id, status_id, set_issue = 0 } = req.body || {}; |       const { rfa_corr_id, status_id, set_issue = 0 } = req.body || {}; | ||||||
|       await callProc('sp_rfa_update_status', [req.user.user_id, rfa_corr_id, status_id, set_issue ? 1 : 0]); |       if (!rfa_corr_id || !status_id) { | ||||||
|  |         return res | ||||||
|  |           .status(400) | ||||||
|  |           .json({ error: "rfa_corr_id and status_id required" }); | ||||||
|  |       } | ||||||
|  |       // enforce ABAC: find project_id of the RFA | ||||||
|  |       const [[ref]] = await sql.query( | ||||||
|  |         "SELECT project_id FROM rfas WHERE id=? LIMIT 1", | ||||||
|  |         [Number(rfa_corr_id)] | ||||||
|  |       ); | ||||||
|  |       if (!ref) return res.status(404).json({ error: "RFA not found" }); | ||||||
|  |       if ( | ||||||
|  |         !req.principal.is_superadmin && | ||||||
|  |         !req.principal.inProject(ref.project_id) | ||||||
|  |       ) { | ||||||
|  |         return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       await callProc("sp_rfa_update_status", [ | ||||||
|  |         req.principal.user_id, | ||||||
|  |         rfa_corr_id, | ||||||
|  |         status_id, | ||||||
|  |         set_issue ? 1 : 0, | ||||||
|  |       ]); | ||||||
|       res.json({ ok: true }); |       res.json({ ok: true }); | ||||||
|     } catch (e) { next(e); } |     } catch (e) { | ||||||
|  |       next(e); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| export default router; | export default r; | ||||||
|   | |||||||
| @@ -1,206 +1,167 @@ | |||||||
| // backend/src/routes/rfas.js  (merged) | // FILE: backend/src/routes/rfas.js | ||||||
| // Base: rfas.js + enhanced list/sort/paging/overdue from rfas-1.js | // RFAs list/get/create/update/delete — มาตรฐาน Bearer + requirePerm | ||||||
|  | import { Router } from "express"; | ||||||
| import { Router } from 'express'; | import sql from "../db/index.js"; | ||||||
| import sql from '../db/index.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; |  | ||||||
| import { buildScopeWhere, ownerResolvers } from '../utils/scope.js'; |  | ||||||
| // import PERM from '../config/permissions.js'; // ถ้าไม่ได้ใช้ สามารถลบได้ |  | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const OWN = ownerResolvers(sql, 'rfas', 'id'); |  | ||||||
|  |  | ||||||
| /* ----------------------------- Utilities ----------------------------- */ |  | ||||||
| // Allow-list สำหรับการ sort ป้องกัน SQL injection |  | ||||||
| const ALLOWED_SORT = new Map([ | const ALLOWED_SORT = new Map([ | ||||||
|   ['updated_at', 'updated_at'], |   ["updated_at", "updated_at"], | ||||||
|   ['due_date', 'due_date'], |   ["due_date", "due_date"], | ||||||
|   ['created_at', 'created_at'], |   ["created_at", "created_at"], | ||||||
|   ['id', 'id'] |   ["id", "id"], | ||||||
| ]); | ]); | ||||||
|  | function parseSort(sort = "updated_at:desc") { | ||||||
| function parseSort(sort = 'updated_at:desc') { |   const [c, d] = String(sort).split(":"); | ||||||
|   const [colRaw, dirRaw] = String(sort).split(':'); |   const col = ALLOWED_SORT.get(c) || "updated_at"; | ||||||
|   const col = ALLOWED_SORT.get(colRaw) || 'updated_at'; |   const dir = (d || "desc").toLowerCase() === "asc" ? "ASC" : "DESC"; | ||||||
|   const dir = (dirRaw || 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC'; |  | ||||||
|   return `\`${col}\` ${dir}`; |   return `\`${col}\` ${dir}`; | ||||||
| } | } | ||||||
|  | function paging({ page = 1, pageSize = 20 }) { | ||||||
| function parsePaging({ page = 1, pageSize = 20 }) { |  | ||||||
|   const p = Math.max(1, Number(page) || 1); |   const p = Math.max(1, Number(page) || 1); | ||||||
|   const ps = Math.min(200, Math.max(1, Number(pageSize) || 20)); |   const ps = Math.min(200, Math.max(1, Number(pageSize) || 20)); | ||||||
|   return { limit: ps, offset: (p - 1) * ps, page: p, pageSize: ps }; |   return { limit: ps, offset: (p - 1) * ps, page: p, pageSize: ps }; | ||||||
| } | } | ||||||
|  |  | ||||||
| // ตัวกรองเพิ่มเติม (จาก rfas-1.js) + ผสมกับเงื่อนไข scope เดิม | // LIST (PROJECT scope enforced: filter ด้วย principal) | ||||||
| function buildExtraFilters({ q, status, overdue, project_id, org_id }) { | r.get( | ||||||
|   const parts = []; |   "/", | ||||||
|   const params = {}; |   requirePerm("rfas.view", { projectParam: "project_id" }), | ||||||
|   if (project_id) { parts.push('r.project_id = :project_id'); params.project_id = Number(project_id); } |  | ||||||
|   if (org_id)     { parts.push('r.org_id = :org_id');         params.org_id = Number(org_id); } |  | ||||||
|   if (status)     { parts.push('r.status = :status');         params.status = status; } |  | ||||||
|   if (q)          { parts.push('(r.rfa_no LIKE :q OR r.title LIKE :q OR r.code LIKE :q)'); params.q = `%${q}%`; } |  | ||||||
|   if (String(overdue) === '1') { |  | ||||||
|     // overdue: due_date < TODAY และสถานะยังไม่ปิด |  | ||||||
|     parts.push("r.due_date IS NOT NULL AND r.due_date < CURDATE() AND r.status NOT IN ('Closed','Approved')"); |  | ||||||
|   } |  | ||||||
|   return { where: parts.join(' AND '), params }; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /* -------------------------------- LIST -------------------------------- |  | ||||||
|  GET /rfas |  | ||||||
|  - คง requirePerm แบบ rfas.js (scope:global + project/org scope ผ่าน buildScopeWhere) |  | ||||||
|  - เพิ่ม faceted filters/sort/paging/overdue จาก rfas-1.js |  | ||||||
| ------------------------------------------------------------------------*/ |  | ||||||
| r.get('/', |  | ||||||
|   requirePerm('rfa.read', { scope: 'global' }), |  | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     try { |     const { q, status, overdue, sort, page, pageSize, project_id } = req.query; | ||||||
|       const { q, status, overdue, sort, page, pageSize, project_id, org_id } = req.query; |  | ||||||
|     const orderBy = parseSort(sort); |     const orderBy = parseSort(sort); | ||||||
|       const { limit, offset, page: p, pageSize: ps } = parsePaging({ page, pageSize }); |     const { limit, offset, page: p, pageSize: ps } = paging({ page, pageSize }); | ||||||
|  |  | ||||||
|       // base scope จาก principal (org/project) |     const P = req.principal; | ||||||
|       const base = buildScopeWhere(req.principal, { |     const cond = []; | ||||||
|         tableAlias: 'r', orgColumn: 'r.org_id', projectColumn: 'r.project_id', |     const params = []; | ||||||
|         permCode: 'rfa.read', preferProject: true, |  | ||||||
|  |     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 | // CREATE | ||||||
|       const extra = buildExtraFilters({ q, status, overdue, project_id, org_id }); | r.post( | ||||||
|  |   "/", | ||||||
|       // รวม where |   requirePerm("rfas.create", { projectParam: "project_id" }), | ||||||
|       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 }), |  | ||||||
|   async (req, res) => { |   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 || {}; |     const { org_id, project_id, rfa_no, title, status } = req.body || {}; | ||||||
|       if (!title?.trim()) return res.status(400).json({ error: 'title is required' }); |     if (!project_id || !title) | ||||||
|  |       return res.status(400).json({ error: "project_id and title required" }); | ||||||
|       const st = String(status || '').trim() || 'draft'; |  | ||||||
|     const [rs] = await sql.query( |     const [rs] = await sql.query( | ||||||
|       `INSERT INTO rfas (org_id, project_id, rfa_no, title, status, created_by, created_at, updated_at) |       `INSERT INTO rfas (org_id, project_id, rfa_no, title, status, created_by, created_at, updated_at) | ||||||
|      VALUES (?,?,?,?,?,?,NOW(),NOW())`, |      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 }); |     res.status(201).json({ id: rs.insertId }); | ||||||
|     } catch (e) { |  | ||||||
|       res.status(500).json({ error: e.message || 'rfas/create failed' }); |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| /* ------------------------------- UPDATE ------------------------------- | // UPDATE (respond/edit) | ||||||
| // PUT: รูปแบบเดิมของ rfas.js (อัปเดต title, status) | r.patch("/:id", requirePerm("rfas.respond"), async (req, res) => { | ||||||
| // PATCH: แบบยืดหยุ่น (บางฟิลด์) ผสานแนวทางจาก rfas-1.js |  | ||||||
| ------------------------------------------------------------------------*/ |  | ||||||
| r.put('/:id', |  | ||||||
|   requirePerm('rfa.update', { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
|   async (req, res) => { |  | ||||||
|     try { |  | ||||||
|   const id = Number(req.params.id); |   const id = Number(req.params.id); | ||||||
|       const { title, status } = req.body || {}; |   const [[row]] = await sql.query("SELECT * FROM rfas WHERE id=?", [id]); | ||||||
|       await sql.query('UPDATE rfas SET title=?, status=?, updated_at=NOW() WHERE id=?', [title, status, id]); |   if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|       res.json({ ok: 1, id }); |   const P = req.principal; | ||||||
|     } catch (e) { |   if (!P.is_superadmin && !P.inProject(row.project_id)) | ||||||
|       res.status(500).json({ error: e.message || 'rfas/update failed' }); |     return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|     } |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| // PATCH แบบ partial fields |   const allowed = [ | ||||||
| r.patch('/:id', |     "code", | ||||||
|   requirePerm('rfa.update', { scope: 'org', getOrgId: OWN.getOrgIdById }), |     "rfa_no", | ||||||
|   async (req, res) => { |     "title", | ||||||
|     try { |     "discipline", | ||||||
|       const id = Number(req.params.id); |     "due_date", | ||||||
|       const allowed = ['code', 'rfa_no', 'title', 'discipline', 'due_date', 'description', 'status', 'owner_id']; |     "description", | ||||||
|  |     "status", | ||||||
|  |     "owner_id", | ||||||
|  |   ]; | ||||||
|   const patch = {}; |   const patch = {}; | ||||||
|   for (const k of allowed) if (k in req.body) patch[k] = req.body[k]; |   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) { |   const sets = Object.keys(patch).map((k) => `\`${k}\`=?`); | ||||||
|         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; |  | ||||||
|  |  | ||||||
|   await sql.query( |   await sql.query( | ||||||
|         `UPDATE rfas SET ${sets.join(', ')}, updated_at=NOW() WHERE id=:id`, |     `UPDATE rfas SET ${sets.join(", ")}, updated_at=NOW() WHERE id=?`, | ||||||
|         patch |     [...Object.values(patch), id] | ||||||
|   ); |   ); | ||||||
|   res.json({ ok: 1, id }); |   res.json({ ok: 1, id }); | ||||||
|     } catch (e) { | }); | ||||||
|       res.status(500).json({ error: e.message || 'rfas/patch failed' }); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| /* ------------------------------- DELETE ------------------------------- */ | // DELETE | ||||||
| r.delete('/:id', | r.delete("/:id", requirePerm("rfas.delete"), async (req, res) => { | ||||||
|   requirePerm('rfa.delete', { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
|   async (req, res) => { |  | ||||||
|     try { |  | ||||||
|   const id = Number(req.params.id); |   const id = Number(req.params.id); | ||||||
|       await sql.query('DELETE FROM rfas WHERE id=?', [id]); |   const [[row]] = await sql.query("SELECT project_id FROM rfas WHERE id=?", [ | ||||||
|       res.json({ ok: 1, id }); |     id, | ||||||
|     } catch (e) { |   ]); | ||||||
|       res.status(500).json({ error: e.message || 'rfas/delete failed' }); |   if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|     } |   const P = req.principal; | ||||||
|   } |   if (!P.is_superadmin && !P.inProject(row.project_id)) | ||||||
| ); |     return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|  |   await sql.query("DELETE FROM rfas WHERE id=?", [id]); | ||||||
|  |   res.json({ ok: 1 }); | ||||||
|  | }); | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
|   | |||||||
| @@ -1,48 +1,95 @@ | |||||||
| import { Router } from 'express'; | // FILE: backend/src/routes/subcategories.js | ||||||
| import { requireAuth } from '../middleware/auth.js'; | // Master data: subcategories — GLOBAL read/write (ตาม categories.js) | ||||||
| import { enrichPermissions } from '../middleware/permissions.js'; | import { Router } from "express"; | ||||||
| import { requireRole } from '../middleware/rbac.js'; | import sql from "../db/index.js"; | ||||||
| import { requirePerm } from '../middleware/permGuard.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
| import { sequelize } from '../db/sequelize.js'; |  | ||||||
| import SubCatModel from '../db/models/SubCategory.js'; |  | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const SubCat = SubCatModel(sequelize); |  | ||||||
|  |  | ||||||
| r.get('/sub_categories', requireAuth, async (req, res) => { | // LIST (GLOBAL read) | ||||||
|   const { q, project_id, page=1, page_size=50 } = req.query; | r.get( | ||||||
|  |   "/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 limit = Math.min(Number(page_size) || 50, 200); | ||||||
|     const offset = (Math.max(Number(page) || 1, 1) - 1) * limit; |     const offset = (Math.max(Number(page) || 1, 1) - 1) * limit; | ||||||
|   const where = {}; |  | ||||||
|   if (project_id) where.project_id = project_id; |     const cond = []; | ||||||
|   if (q) where.sub_cat_name = sequelize.where(sequelize.fn('LOWER', sequelize.col('sub_cat_name')), 'LIKE', `%${String(q).toLowerCase()}%`); |     const params = []; | ||||||
|   const { rows, count } = await SubCat.findAndCountAll({ where, limit, offset, order:[['sub_cat_name','ASC']] }); |     if (cat_id) { | ||||||
|   res.json({ items: rows, total: count, page: Number(page), page_size: limit }); |       cond.push("cat_id=?"); | ||||||
|  |       params.push(Number(cat_id)); | ||||||
|  |     } | ||||||
|  |     if (q) { | ||||||
|  |       cond.push("LOWER(sub_cat_name) LIKE ?"); | ||||||
|  |       params.push(`%${String(q).toLowerCase()}%`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; | ||||||
|  |     const [[{ cnt }]] = await sql.query( | ||||||
|  |       `SELECT COUNT(*) AS cnt FROM subcategories ${where}`, | ||||||
|  |       params | ||||||
|  |     ); | ||||||
|  |     const [rows] = await sql.query( | ||||||
|  |       `SELECT * FROM subcategories ${where} ORDER BY sub_cat_name ASC LIMIT ? OFFSET ?`, | ||||||
|  |       [...params, limit, offset] | ||||||
|  |     ); | ||||||
|  |     res.json({ | ||||||
|  |       items: rows, | ||||||
|  |       total: Number(cnt || 0), | ||||||
|  |       page: Number(page) || 1, | ||||||
|  |       page_size: limit, | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | // CREATE (GLOBAL write) | ||||||
|  | r.post("/sub_categories", requirePerm("settings.manage"), async (req, res) => { | ||||||
|  |   const { cat_id, sub_cat_name, code } = req.body || {}; | ||||||
|  |   if (!cat_id || !sub_cat_name) | ||||||
|  |     return res.status(400).json({ error: "cat_id and sub_cat_name required" }); | ||||||
|  |   const [rs] = await sql.query( | ||||||
|  |     "INSERT INTO subcategories (cat_id, sub_cat_name, code) VALUES (?,?,?)", | ||||||
|  |     [Number(cat_id), sub_cat_name, code ?? null] | ||||||
|  |   ); | ||||||
|  |   res.status(201).json({ sub_cat_id: rs.insertId }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| r.post('/sub_categories', requireAuth, enrichPermissions(), requirePerm('lookup:edit'), async (req, res) => { | // UPDATE | ||||||
|   const { project_id, sub_cat_name, parent_cat_id, code } = req.body || {}; | r.patch( | ||||||
|   if (!project_id || !sub_cat_name) return res.status(400).json({ error: 'project_id and sub_cat_name required' }); |   "/sub_categories/:id", | ||||||
|   const created = await SubCat.create({ project_id, sub_cat_name, parent_cat_id, code }); |   requirePerm("settings.manage"), | ||||||
|   res.status(201).json({ sub_cat_id: created.sub_cat_id }); |   async (req, res) => { | ||||||
| }); |     const id = Number(req.params.id); | ||||||
|  |     const [[row]] = await sql.query( | ||||||
| r.patch('/sub_categories/:id', requireAuth, enrichPermissions(), requirePerm('lookup:edit'), async (req, res) => { |       "SELECT * FROM subcategories WHERE sub_cat_id=?", | ||||||
|   const row = await SubCat.findByPk(Number(req.params.id)); |       [id] | ||||||
|   if (!row) return res.status(404).json({ error: 'Not found' }); |     ); | ||||||
|   const { sub_cat_name, parent_cat_id, code } = req.body || {}; |     if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|   if (sub_cat_name !== undefined) row.sub_cat_name = sub_cat_name; |     const { sub_cat_name, cat_id, code } = req.body || {}; | ||||||
|   if (parent_cat_id !== undefined) row.parent_cat_id = parent_cat_id; |     await sql.query( | ||||||
|   if (code !== undefined) row.code = code; |       "UPDATE subcategories SET sub_cat_name=?, cat_id=?, code=? WHERE sub_cat_id=?", | ||||||
|   await row.save(); |       [ | ||||||
|  |         sub_cat_name ?? row.sub_cat_name, | ||||||
|  |         cat_id ?? row.cat_id, | ||||||
|  |         code ?? row.code, | ||||||
|  |         id, | ||||||
|  |       ] | ||||||
|  |     ); | ||||||
|     res.json({ ok: true }); |     res.json({ ok: true }); | ||||||
| }); |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
| r.delete('/sub_categories/:id', requireAuth, enrichPermissions(), requirePerm('lookup:edit'), async (req, res) => { | // DELETE | ||||||
|   const row = await SubCat.findByPk(Number(req.params.id)); | r.delete( | ||||||
|   if (!row) return res.status(404).json({ error: 'Not found' }); |   "/sub_categories/:id", | ||||||
|   await row.destroy(); |   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 }); |     res.json({ ok: true }); | ||||||
| }); |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
|   | |||||||
| @@ -1,190 +1,124 @@ | |||||||
| // src/routes/technicaldocs.js (ESM) | // FILE: backend/src/routes/technicaldocs.js | ||||||
| import { Router } from 'express'; | // แมปเป็นเอกสารประเภทหนึ่ง → ใช้สิทธิ์ documents.view/manage (PROJECT) | ||||||
| import sql from '../db/index.js'; | import { Router } from "express"; | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | import sql from "../db/index.js"; | ||||||
| import { buildScopeWhere, ownerResolvers } from '../utils/scope.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
| import PERM from '../config/permissions.js'; |  | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const OWN = ownerResolvers(sql, 'technicaldocs', 'id'); |  | ||||||
|  |  | ||||||
| // LIST (filter + scope) |  | ||||||
| r.get('/', |  | ||||||
|   requirePerm(PERM.technicaldoc.read, { scope: 'global' }), |  | ||||||
|   async (req, res) => { |  | ||||||
|     const { project_id, org_id, status, q, limit = 50, offset = 0 } = req.query; |  | ||||||
|  |  | ||||||
|     const base = buildScopeWhere(req.principal, { |  | ||||||
|       tableAlias: 't', |  | ||||||
|       orgColumn: 't.org_id',import { Router } from 'express'; |  | ||||||
| import sql from '../db/index.js'; |  | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; |  | ||||||
| import { buildScopeWhere, ownerResolvers } from '../utils/scope.js'; |  | ||||||
| import PERM from '../config/permissions.js'; |  | ||||||
|  |  | ||||||
| const r = Router(); |  | ||||||
| const OWN = ownerResolvers(sql, 'transmittals', 'id'); |  | ||||||
|  |  | ||||||
| // LIST | // LIST | ||||||
| r.get('/', | r.get( | ||||||
|   requirePerm(PERM.transmittal.read, { scope: 'global' }), |   "/", | ||||||
|  |   requirePerm("documents.view", { projectParam: "project_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const { project_id, org_id, tr_no, q, limit = 50, offset = 0 } = req.query; |     const { project_id, status, q, limit = 50, offset = 0 } = req.query; | ||||||
|  |     const P = req.principal; | ||||||
|     const base = buildScopeWhere(req.principal, { |     const cond = []; | ||||||
|       tableAlias: 't', |  | ||||||
|       orgColumn: 't.org_id', |  | ||||||
|       projectColumn: 't.project_id', |  | ||||||
|       permCode: PERM.transmittal.read, |  | ||||||
|       preferProject: true, |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     const extra = []; |  | ||||||
|     const params = { ...base.params, limit: Number(limit), offset: Number(offset) }; |  | ||||||
|     if (project_id) { extra.push('t.project_id = :project_id'); params.project_id = Number(project_id); } |  | ||||||
|     if (org_id)     { extra.push('t.org_id = :org_id');         params.org_id = Number(org_id); } |  | ||||||
|     if (tr_no)      { extra.push('t.tr_no = :tr_no');           params.tr_no = tr_no; } |  | ||||||
|     if (q)          { extra.push('(t.tr_no LIKE :q OR t.subject LIKE :q)'); params.q = `%${q}%`; } |  | ||||||
|  |  | ||||||
|     const where = [base.where, ...extra].filter(Boolean).join(' AND '); |  | ||||||
|  |  | ||||||
|     const [rows] = await sql.query( |  | ||||||
|       `SELECT t.* FROM transmittals t |  | ||||||
|        WHERE ${where} |  | ||||||
|        ORDER BY t.id DESC |  | ||||||
|        LIMIT :limit OFFSET :offset`, |  | ||||||
|       params |  | ||||||
|     ); |  | ||||||
|     res.json(rows); |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| // GET |  | ||||||
| r.get('/:id', |  | ||||||
|   requirePerm(PERM.transmittal.read, { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
|   async (req, res) => { |  | ||||||
|     const id = Number(req.params.id); |  | ||||||
|     const [[row]] = await sql.query('SELECT * FROM transmittals WHERE id=?', [id]); |  | ||||||
|     if (!row) return res.status(404).json({ error: 'Not found' }); |  | ||||||
|     res.json(row); |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| // CREATE |  | ||||||
| r.post('/', |  | ||||||
|   requirePerm(PERM.transmittal.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }), |  | ||||||
|   async (req, res) => { |  | ||||||
|     const { org_id, project_id, tr_no, subject, status } = req.body; |  | ||||||
|     const [rs] = await sql.query( |  | ||||||
|       `INSERT INTO transmittals (org_id, project_id, tr_no, subject, status, created_by) |  | ||||||
|        VALUES (?,?,?,?,?,?)`, |  | ||||||
|       [org_id, project_id, tr_no, subject, status, req.principal.userId] |  | ||||||
|     ); |  | ||||||
|     res.json({ id: rs.insertId }); |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| // UPDATE (รองรับ PATCH) |  | ||||||
| r.patch('/:id', |  | ||||||
|   requirePerm(PERM.transmittal.update, { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
|   async (req, res) => { |  | ||||||
|     const id = Number(req.params.id); |  | ||||||
|     const fields = []; |  | ||||||
|     const params = []; |     const params = []; | ||||||
|  |     if (!P.is_superadmin) { | ||||||
|     // อนุญาตแก้ฟิลด์หลัก |       if (project_id) { | ||||||
|     const allow = ['tr_no','subject','status']; |         const pid = Number(project_id); | ||||||
|     for (const k of allow) { |         if (!P.inProject(pid)) | ||||||
|       if (k in req.body) { |           return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|         fields.push(`${k} = ?`); |         cond.push("t.project_id=?"); | ||||||
|         params.push(req.body[k]); |         params.push(pid); | ||||||
|       } |       } else if (P.project_ids?.length) { | ||||||
|     } |         cond.push( | ||||||
|     if (!fields.length) return res.status(400).json({ error: 'NO_FIELDS' }); |           `t.project_id IN (${P.project_ids.map(() => "?").join(",")})` | ||||||
|  |  | ||||||
|     params.push(id); |  | ||||||
|     await sql.query(`UPDATE transmittals SET ${fields.join(', ')} WHERE id=?`, params); |  | ||||||
|     res.json({ ok: 1 }); |  | ||||||
|   } |  | ||||||
|         ); |         ); | ||||||
|  |         params.push(...P.project_ids); | ||||||
| // DELETE |       } | ||||||
| r.delete('/:id', |     } else if (project_id) { | ||||||
|   requirePerm(PERM.transmittal.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }), |       cond.push("t.project_id=?"); | ||||||
|   async (req, res) => { |       params.push(Number(project_id)); | ||||||
|     const id = Number(req.params.id); |  | ||||||
|     await sql.query('DELETE FROM transmittals WHERE id=?', [id]); |  | ||||||
|     res.json({ ok: 1 }); |  | ||||||
|     } |     } | ||||||
| ); |  | ||||||
|  |  | ||||||
| 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', |     const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; | ||||||
|       permCode: PERM.technicaldoc.read, |  | ||||||
|       preferProject: true, |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     const extra = []; |  | ||||||
|     const params = { ...base.params, limit: Number(limit), offset: Number(offset) }; |  | ||||||
|     if (project_id) { extra.push('t.project_id = :project_id'); params.project_id = Number(project_id); } |  | ||||||
|     if (org_id)     { extra.push('t.org_id = :org_id');         params.org_id = Number(org_id); } |  | ||||||
|     if (status)     { extra.push('t.status = :status');         params.status = status; } |  | ||||||
|     if (q)          { extra.push('(t.doc_no LIKE :q OR t.title LIKE :q)'); params.q = `%${q}%`; } |  | ||||||
|  |  | ||||||
|     const where = [base.where, ...extra].filter(Boolean).join(' AND '); |  | ||||||
|     const [rows] = await sql.query( |     const [rows] = await sql.query( | ||||||
|       `SELECT t.* FROM technicaldocs t WHERE ${where} |       `SELECT t.* FROM technicaldocs t ${where} ORDER BY t.id DESC LIMIT ? OFFSET ?`, | ||||||
|        ORDER BY t.id DESC LIMIT :limit OFFSET :offset`, params |       [...params, Number(limit), Number(offset)] | ||||||
|     ); |     ); | ||||||
|     res.json(rows); |     res.json(rows); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| // GET | // GET | ||||||
| r.get('/:id', | r.get("/:id", requirePerm("documents.view"), async (req, res) => { | ||||||
|   requirePerm(PERM.technicaldoc.read, { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
|   async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   const id = Number(req.params.id); | ||||||
|     const [[row]] = await sql.query('SELECT * FROM technicaldocs WHERE id=?', [id]); |   const [[row]] = await sql.query("SELECT * FROM technicaldocs WHERE id=?", [ | ||||||
|     if (!row) return res.status(404).json({ error: 'Not found' }); |     id, | ||||||
|  |   ]); | ||||||
|  |   if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|  |   const P = req.principal; | ||||||
|  |   if (!P.is_superadmin && !P.inProject(row.project_id)) | ||||||
|  |     return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|   res.json(row); |   res.json(row); | ||||||
|   } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
| // CREATE | // CREATE | ||||||
| r.post('/', | r.post( | ||||||
|   requirePerm(PERM.technicaldoc.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }), |   "/", | ||||||
|  |   requirePerm("documents.manage", { projectParam: "project_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const { org_id, project_id, doc_no, title, status } = req.body; |     const { org_id, project_id, doc_no, title, status } = req.body || {}; | ||||||
|  |     if (!project_id || !doc_no) | ||||||
|  |       return res.status(400).json({ error: "project_id and doc_no required" }); | ||||||
|     const [rs] = await sql.query( |     const [rs] = await sql.query( | ||||||
|       `INSERT INTO technicaldocs (org_id, project_id, doc_no, title, status, created_by) |       `INSERT INTO technicaldocs (org_id, project_id, doc_no, title, status, created_by) | ||||||
|      VALUES (?,?,?,?,?,?)`, |      VALUES (?,?,?,?,?,?)`, | ||||||
|       [org_id, project_id, doc_no, title, status, req.principal.userId] |       [ | ||||||
|  |         org_id ?? null, | ||||||
|  |         project_id, | ||||||
|  |         doc_no, | ||||||
|  |         title ?? null, | ||||||
|  |         status ?? null, | ||||||
|  |         req.principal.user_id, | ||||||
|  |       ] | ||||||
|     ); |     ); | ||||||
|     res.json({ id: rs.insertId }); |     res.status(201).json({ id: rs.insertId }); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| // UPDATE | // UPDATE | ||||||
| r.put('/:id', | r.put("/:id", requirePerm("documents.manage"), async (req, res) => { | ||||||
|   requirePerm(PERM.technicaldoc.update, { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
|   async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   const id = Number(req.params.id); | ||||||
|     const { title, status } = req.body; |   const [[row]] = await sql.query("SELECT * FROM technicaldocs WHERE id=?", [ | ||||||
|     await sql.query('UPDATE technicaldocs SET title=?, status=? WHERE id=?', [title, status, 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 }); |   res.json({ ok: 1 }); | ||||||
|   } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
| // DELETE | // DELETE | ||||||
| r.delete('/:id', | r.delete("/:id", requirePerm("documents.manage"), async (req, res) => { | ||||||
|   requirePerm(PERM.technicaldoc.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
|   async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   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 }); |   res.json({ ok: 1 }); | ||||||
|   } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
|   | |||||||
| @@ -1,204 +1,131 @@ | |||||||
| // backend/src/routes/transmittals.js (merged) | // FILE: backend/src/routes/transmittals.js | ||||||
| // Base: transmittals.js + list/sort/paging from transmittals-1.js | // ทั้งโมดูลใช้สิทธิ์เดียว: transmittals.manage (PROJECT) | ||||||
| // Notes: | import { Router } from "express"; | ||||||
| // - ยึด RBAC/Scope/Permissions แบบเดิมของ transmittals.js | import sql from "../db/index.js"; | ||||||
| // - Faceted list -> ส่ง meta { data, total, page, pageSize } | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
| // - PATCH: อัปเดตบางฟิลด์อย่างปลอดภัย (ไม่บังคับให้มีคอลัมน์ใหม่ใน DB) |  | ||||||
|  |  | ||||||
| import { Router } from 'express'; |  | ||||||
| import sql from '../db/index.js'; |  | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; |  | ||||||
| import { buildScopeWhere, ownerResolvers } from '../utils/scope.js'; |  | ||||||
| import PERM from '../config/permissions.js'; |  | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const OWN = ownerResolvers(sql, 'transmittals', 'id'); |  | ||||||
|  |  | ||||||
| /* ----------------------------- Utilities ----------------------------- */ | // LIST | ||||||
| // จำกัดฟิลด์ที่อนุญาตให้ sort เพื่อลดความเสี่ยง SQLi | r.get( | ||||||
| const ALLOWED_SORT = new Map([ |   "/", | ||||||
|   ['updated_at', 'updated_at'], |   requirePerm("transmittals.manage", { projectParam: "project_id" }), | ||||||
|   ['created_at', 'created_at'], |  | ||||||
|   ['id', 'id'], |  | ||||||
|   ['tr_no', 'tr_no'], |  | ||||||
|   ['subject', 'subject'], |  | ||||||
| ]); |  | ||||||
| function parseSort(sort = 'updated_at:desc') { |  | ||||||
|   const [colRaw, dirRaw] = String(sort).split(':'); |  | ||||||
|   const col = ALLOWED_SORT.get(colRaw) || 'updated_at'; |  | ||||||
|   const dir = (dirRaw || 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC'; |  | ||||||
|   return `\`${col}\` ${dir}`; |  | ||||||
| } |  | ||||||
| function parsePaging({ page = 1, pageSize = 20 }) { |  | ||||||
|   const p = Math.max(1, Number(page) || 1); |  | ||||||
|   const ps = Math.min(200, Math.max(1, Number(pageSize) || 20)); |  | ||||||
|   return { limit: ps, offset: (p - 1) * ps, page: p, pageSize: ps }; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function buildExtraFilters({ project_id, org_id, tr_no, q }) { |  | ||||||
|   const extra = []; |  | ||||||
|   const params = {}; |  | ||||||
|   if (project_id) { extra.push('t.project_id = :project_id'); params.project_id = Number(project_id); } |  | ||||||
|   if (org_id)     { extra.push('t.org_id = :org_id');         params.org_id = Number(org_id); } |  | ||||||
|   if (tr_no)      { extra.push('t.tr_no = :tr_no');           params.tr_no = tr_no; } |  | ||||||
|   if (q) { |  | ||||||
|     // ใช้ฟิลด์พื้นฐานที่ transmittals.js มีแน่นอน (tr_no, subject) |  | ||||||
|     extra.push('(t.tr_no LIKE :q OR t.subject LIKE :q)'); |  | ||||||
|     params.q = `%${q}%`; |  | ||||||
|   } |  | ||||||
|   return { where: extra.join(' AND '), params }; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /* -------------------------------- LIST -------------------------------- |  | ||||||
| GET /transmittals |  | ||||||
| - คง RBAC/Scope เดิม (global + project/org scope ผ่าน buildScopeWhere) |  | ||||||
| - เพิ่ม sort/page/pageSize/q ตามสไตล์ transmittals-1.js และตอบ meta |  | ||||||
| ------------------------------------------------------------------------*/ |  | ||||||
| r.get('/', |  | ||||||
|   requirePerm(PERM.transmittal.read, { scope: 'global' }), |  | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     try { |     const { project_id, tr_no, q, limit = 50, offset = 0 } = req.query; | ||||||
|       const { project_id, org_id, tr_no, q, sort, page, pageSize } = req.query; |     const P = req.principal; | ||||||
|       const orderBy = parseSort(sort); |     const cond = []; | ||||||
|       const { limit, offset, page: p, pageSize: ps } = parsePaging({ page, pageSize }); |     const params = []; | ||||||
|  |  | ||||||
|       const base = buildScopeWhere(req.principal, { |     if (!P.is_superadmin) { | ||||||
|         tableAlias: 't', |       if (project_id) { | ||||||
|         orgColumn: 't.org_id', |         const pid = Number(project_id); | ||||||
|         projectColumn: 't.project_id', |         if (!P.inProject(pid)) | ||||||
|         permCode: PERM.transmittal.read, |           return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
|         preferProject: true |         cond.push("t.project_id=?"); | ||||||
|  |         params.push(pid); | ||||||
|  |       } else if (P.project_ids?.length) { | ||||||
|  |         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 }); | // CREATE | ||||||
|       const where = [base.where, extra.where].filter(Boolean).join(' AND ') || '1=1'; | r.post( | ||||||
|       const params = { ...base.params, ...extra.params, limit, offset }; |   "/", | ||||||
|  |   requirePerm("transmittals.manage", { projectParam: "project_id" }), | ||||||
|       // total |  | ||||||
|       const [[{ cnt: total }]] = await sql.query( |  | ||||||
|         `SELECT COUNT(*) AS cnt FROM transmittals t WHERE ${where}`, |  | ||||||
|         params |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       // rows |  | ||||||
|       const [rows] = await sql.query( |  | ||||||
|         `SELECT t.*  |  | ||||||
|          FROM transmittals t |  | ||||||
|          WHERE ${where} |  | ||||||
|          ORDER BY ${orderBy} |  | ||||||
|          LIMIT :limit OFFSET :offset`, |  | ||||||
|         params |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       res.json({ data: rows, total: Number(total || 0), page: p, pageSize: ps }); |  | ||||||
|     } catch (e) { |  | ||||||
|       res.status(500).json({ error: e.message || 'transmittals/list failed' }); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| /* ------------------------------- GET ONE ------------------------------ */ |  | ||||||
| r.get('/:id', |  | ||||||
|   requirePerm(PERM.transmittal.read, { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     try { |     const { org_id, project_id, tr_no, subject, status } = req.body || {}; | ||||||
|       const id = Number(req.params.id); |     if (!project_id || !tr_no) | ||||||
|       const [[row]] = await sql.query('SELECT * FROM transmittals WHERE id=?', [id]); |       return res.status(400).json({ error: "project_id and tr_no required" }); | ||||||
|       if (!row) return res.status(404).json({ error: 'Not found' }); |  | ||||||
|       res.json(row); |  | ||||||
|     } catch (e) { |  | ||||||
|       res.status(500).json({ error: e.message || 'transmittals/detail failed' }); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| /* -------------------------------- CREATE ------------------------------ */ |  | ||||||
| r.post('/', |  | ||||||
|   requirePerm(PERM.transmittal.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }), |  | ||||||
|   async (req, res) => { |  | ||||||
|     try { |  | ||||||
|       // ยึดสคีมาหลักจาก transmittals.js |  | ||||||
|       const { org_id, project_id, tr_no, subject, status } = req.body; |  | ||||||
|     const [rs] = await sql.query( |     const [rs] = await sql.query( | ||||||
|       `INSERT INTO transmittals (org_id, project_id, tr_no, subject, status, created_by) |       `INSERT INTO transmittals (org_id, project_id, tr_no, subject, status, created_by) | ||||||
|      VALUES (?,?,?,?,?,?)`, |      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 }); |     res.status(201).json({ id: rs.insertId }); | ||||||
|     } catch (e) { |  | ||||||
|       res.status(500).json({ error: e.message || 'transmittals/create failed' }); |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
| /* -------------------------------- UPDATE ------------------------------ */ | // UPDATE | ||||||
| // PUT: รูปแบบเดิม (อัปเดต subject, status) | r.patch("/:id", requirePerm("transmittals.manage"), async (req, res) => { | ||||||
| r.put('/:id', |  | ||||||
|   requirePerm(PERM.transmittal.update, { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
|   async (req, res) => { |  | ||||||
|     try { |  | ||||||
|   const id = Number(req.params.id); |   const id = Number(req.params.id); | ||||||
|       const { subject, status } = req.body; |   const [[row]] = await sql.query("SELECT * FROM transmittals WHERE id=?", [ | ||||||
|       await sql.query('UPDATE transmittals SET subject=?, status=? WHERE id=?', [subject, status, id]); |     id, | ||||||
|       res.json({ ok: 1 }); |   ]); | ||||||
|     } catch (e) { |   if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|       res.status(500).json({ error: e.message || 'transmittals/update failed' }); |   const P = req.principal; | ||||||
|     } |   if (!P.is_superadmin && !P.inProject(row.project_id)) | ||||||
|   } |     return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); | ||||||
| ); |  | ||||||
|  |  | ||||||
| // 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 allowed = ["tr_no", "subject", "status"]; | ||||||
|   const patch = {}; |   const patch = {}; | ||||||
|   for (const k of allowed) if (k in req.body) patch[k] = req.body[k]; |   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) { |   const sets = Object.keys(patch).map((k) => `\`${k}\`=?`); | ||||||
|         return res.status(400).json({ error: 'no fields to update' }); |   await sql.query(`UPDATE transmittals SET ${sets.join(", ")} WHERE id=?`, [ | ||||||
|       } |     ...Object.values(patch), | ||||||
|  |     id, | ||||||
|       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]); |  | ||||||
|   res.json({ ok: 1 }); |   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; | export default r; | ||||||
|   | |||||||
| @@ -1,67 +1,108 @@ | |||||||
| import { Router } from 'express'; | // FILE: backend/src/routes/uploads.js | ||||||
| import multer from 'multer'; | // อัปโหลดไฟล์ผูกกับโมดูล (PROJECT scope): documents/drawings/correspondences/rfas/transmittals | ||||||
| import fs from 'node:fs'; | import { Router } from "express"; | ||||||
| import path from 'node:path'; | import multer from "multer"; | ||||||
| import sql from '../db/index.js'; | import fs from "node:fs"; | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | import path from "node:path"; | ||||||
| import PERM from '../config/permissions.js'; | import sql from "../db/index.js"; | ||||||
|  | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const UPLOAD_BASE = process.env.UPLOAD_BASE || '/share/dms-data'; | const UPLOAD_BASE = process.env.UPLOAD_BASE || "/share/dms-data"; | ||||||
| function ensureDir(p) { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); } | const ensureDir = (p) => { | ||||||
|  |   if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | async function fetchRef(module, id) { | ||||||
|  |   const 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({ | const storage = multer.diskStorage({ | ||||||
| destination: async (req, file, cb) => { |   destination: async (req, _file, cb) => { | ||||||
|     try { |     try { | ||||||
|       const { module, id } = req.params; |       const { module, id } = req.params; | ||||||
| const [[row]] = await sql.query(`SELECT org_id, project_id, created_at FROM ${module} WHERE id=?`, [Number(id)]); |       const ref = await fetchRef(module, id); | ||||||
| if (!row) return cb(new Error('Resource not found')); |       if (!ref) return cb(new Error("Resource not found")); | ||||||
| const dt = new Date(row.created_at || Date.now()); |       const dt = new Date(ref.created_at || Date.now()); | ||||||
| const ym = `${dt.getUTCFullYear()}-${String(dt.getUTCMonth()+1).padStart(2,'0')}`; |       const ym = `${dt.getUTCFullYear()}-${String( | ||||||
| const dir = path.join(UPLOAD_BASE, module, String(row.org_id), String(row.project_id), ym); |         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); |       ensureDir(dir); | ||||||
|       cb(null, dir); |       cb(null, dir); | ||||||
| } catch (e) { cb(e); } |     } catch (e) { | ||||||
| }, |       cb(e); | ||||||
| filename: (req, file, cb) => { |  | ||||||
| const ts = Date.now(); |  | ||||||
| const safe = file.originalname.replace(/[\^\w.\-]+/g, '_'); |  | ||||||
| cb(null, `${ts}__${safe}`); |  | ||||||
|     } |     } | ||||||
|  |   }, | ||||||
|  |   filename: (_req, file, cb) => | ||||||
|  |     cb(null, `${Date.now()}__${sanitize(file.originalname)}`), | ||||||
| }); | }); | ||||||
| const upload = multer({ storage }); | const upload = multer({ storage }); | ||||||
|  |  | ||||||
|  | // map module -> permission | ||||||
| const PERM_UPLOAD = { | function uploadPerm(module) { | ||||||
| correspondences: PERM.correspondence.upload, |   if (module === "documents") return "documents.manage"; | ||||||
| rfas: PERM.rfa.upload, |   if (module === "drawings") return "drawings.upload"; | ||||||
| drawings: PERM.drawing.upload, |   if (module === "correspondences") return "corr.manage"; | ||||||
| transmittals: PERM.transmittal?.upload, |   if (module === "rfas") return "rfas.respond"; | ||||||
| }; |   if (module === "transmittals") return "transmittals.manage"; | ||||||
|  |   return null; | ||||||
|  | } | ||||||
| async function getProjectIdByModule(req){ | async function refProjectId(module, id) { | ||||||
| const { module, id } = req.params; |   const [[row]] = await sql.query( | ||||||
| const [[row]] = await sql.query(`SELECT project_id FROM ${module} WHERE id=?`, [Number(id)]); |     `SELECT project_id FROM ${module} WHERE id=?`, | ||||||
|  |     [Number(id)] | ||||||
|  |   ); | ||||||
|   return row?.project_id ?? null; |   return row?.project_id ?? null; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | r.post( | ||||||
| r.post('/:module/:id/file', |   "/:module/:id/file", | ||||||
|   (req, res, next) => { |   (req, res, next) => { | ||||||
| const perm = PERM_UPLOAD[req.params.module]; |     const perm = uploadPerm(req.params.module); | ||||||
| if (!perm) return res.status(400).json({ error: 'Unsupported module' }); |     if (!perm) return res.status(400).json({ error: "Unsupported module" }); | ||||||
| return requirePerm(perm, { scope: 'project', getProjectId: getProjectIdByModule })(req, res, next); |     return requirePerm(perm, { projectParam: undefined })(req, res, next); | ||||||
|   }, |   }, | ||||||
| upload.single('file'), |   async (req, res, next) => { | ||||||
|  |     // 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) => { |   async (req, res) => { | ||||||
|     const { module, id } = req.params; |     const { module, id } = req.params; | ||||||
|     const file = req.file; |     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; | 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'; | // FILE: backend/src/routes/users.js | ||||||
| import sql from '../db/index.js'; | import { Router } from "express"; | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | import sql from "../db/index.js"; | ||||||
| import PERM from '../config/permissions.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
|  |  | ||||||
| // ME | // ME (ทุกคน) | ||||||
| r.get('/me', async (req, res) => { | r.get("/me", async (req, res) => { | ||||||
|   const [[u]] = await sql.query('SELECT user_id, username, email, first_name, last_name FROM users WHERE user_id=?', |   const p = req.principal; | ||||||
|     [req.principal.userId]); |   const [[u]] = await sql.query( | ||||||
|   if (!u) return res.status(404).json({ error: 'User not found' }); |     `SELECT user_id, username, email, first_name, last_name, org_id FROM users WHERE user_id=?`, | ||||||
|  |     [p.user_id] | ||||||
|   // roles in plain |   ); | ||||||
|   const [roles] = await sql.query(` |   if (!u) return res.status(404).json({ error: "User not found" }); | ||||||
|     SELECT r.role_code, r.role_name, ur.org_id, ur.project_id |   const [roles] = await sql.query( | ||||||
|  |     `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 |        FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id | ||||||
|     WHERE ur.user_id=?`, [req.principal.userId]); |       WHERE ur.user_id=?`, | ||||||
|  |     [p.user_id] | ||||||
|   res.json({ ...u, roles, role_codes: [...req.principal.roleCodes] }); |   ); | ||||||
|  |   res.json({ | ||||||
|  |     ...u, | ||||||
|  |     roles, | ||||||
|  |     role_codes: roles.map((r) => r.role_code), | ||||||
|  |     permissions: [...(p.permissions || [])], | ||||||
|  |     project_ids: p.project_ids, | ||||||
|  |     org_ids: p.org_ids, | ||||||
|  |     is_superadmin: p.is_superadmin, | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| // (optional) USERS LIST – ให้เฉพาะ SUPER_ADMIN หรือ ADMIN (ใน org ตัวเอง) | // USERS LIST (ORG scope) — admin.access | ||||||
| r.get('/', | r.get( | ||||||
|   requirePerm('user.read', { scope: 'global' }), |   "/", | ||||||
|  |   requirePerm("admin.access", { orgParam: "org_id" }), | ||||||
|   async (req, res) => { |   async (req, res) => { | ||||||
|     const [rows] = await sql.query('SELECT user_id, username, email FROM users LIMIT 200'); |     const P = req.principal; | ||||||
|  |     let rows = []; | ||||||
|  |     if (P.is_superadmin) { | ||||||
|  |       [rows] = await sql.query( | ||||||
|  |         "SELECT user_id, username, email, org_id FROM users ORDER BY user_id DESC LIMIT 500" | ||||||
|  |       ); | ||||||
|  |     } else if (P.org_ids?.length) { | ||||||
|  |       const inSql = P.org_ids.map(() => "?").join(","); | ||||||
|  |       [rows] = await sql.query( | ||||||
|  |         `SELECT user_id, username, email, org_id FROM users WHERE org_id IN (${inSql}) ORDER BY user_id DESC LIMIT 500`, | ||||||
|  |         P.org_ids | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|     res.json(rows); |     res.json(rows); | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|   | |||||||
| @@ -1,55 +1,39 @@ | |||||||
| import { Router } from 'express'; | // FILE: backend/src/routes/users_extras.js | ||||||
| import { requireAuth } from '../middleware/auth.js'; | // NOTE: ของเดิมใช้ cookie + Sequelize -> ปรับให้อยู่หลัง Bearer stack และจำกัดความสามารถ | ||||||
| import { requireRole } from '../middleware/rbac.js'; | import { Router } from "express"; | ||||||
| import { User } from '../db/sequelize.js'; | import sql from "../db/index.js"; | ||||||
| import { hashPassword } from '../utils/passwords.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
| import { sequelize } from '../db/sequelize.js'; |  | ||||||
| import UPRModel from '../db/models/UserProjectRole.js'; |  | ||||||
| import ProjectModel from '../db/models/Project.js'; |  | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const UPR = UPRModel(sequelize); |  | ||||||
| const Project = ProjectModel(sequelize); |  | ||||||
|  |  | ||||||
| // self or admin change password | /** | ||||||
| r.patch('/users/:id/password', requireAuth, async (req, res) => { |  * PATCH /users/:id/password | ||||||
|   const targetId = Number(req.params.id); |  * เฉพาะผู้มี settings.manage (GLOBAL) — (คำเตือน: ต้องมีระบบ hash/rotate ที่ service auth) | ||||||
|   const isSelf = req.user?.user_id === targetId; |  */ | ||||||
|   const isAdmin = (req.user?.roles || []).includes('Admin'); | r.patch( | ||||||
|   if (!isSelf && !isAdmin) return res.status(403).json({ error: 'Forbidden' }); |   "/users/:id/password", | ||||||
|  |   requirePerm("settings.manage"), | ||||||
|  |   async (_req, res) => { | ||||||
|  |     // ในโปรเจคนี้การเปลี่ยนรหัสผ่านควรวิ่งที่ auth service/procedure โดยเฉพาะ | ||||||
|  |     return res | ||||||
|  |       .status(501) | ||||||
|  |       .json({ error: "Not implemented here. Use auth service." }); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|   const { new_password } = req.body || {}; | /** | ||||||
|   if (!new_password) return res.status(400).json({ error: 'new_password required' }); |  * GET /users/me/projects — สรุปโปรเจ็ค/บทบาทของผู้ใช้ | ||||||
|  |  */ | ||||||
|   const row = await User.findByPk(targetId); | r.get("/users/me/projects", async (req, res) => { | ||||||
|   if (!row) return res.status(404).json({ error: 'Not found' }); |   const uid = req.principal.user_id; | ||||||
|  |   const [rows] = await sql.query( | ||||||
|   row.password_hash = await hashPassword(new_password); |     `SELECT upr.project_id, r.role_code, r.role_name | ||||||
|   await row.save(); |        FROM user_project_roles upr | ||||||
|   res.json({ ok: true }); |        JOIN roles r ON r.role_id = upr.role_id | ||||||
| }); |       WHERE upr.user_id=? ORDER BY upr.project_id`, | ||||||
|  |     [uid] | ||||||
| // user search (autocomplete) |   ); | ||||||
| r.get('/users/search', requireAuth, requireRole('Admin'), async (req, res) => { |  | ||||||
|   const q = String(req.query.q || '').toLowerCase(); |  | ||||||
|   const where = q ? { |  | ||||||
|     username: sequelize.where(sequelize.fn('LOWER', sequelize.col('username')), 'LIKE', `%${q}%`), |  | ||||||
|   } : {}; |  | ||||||
|   const rows = await User.findAll({ where, limit: 20, order:[['username','ASC']], attributes:['user_id','username','first_name','last_name','email'] }); |  | ||||||
|   res.json(rows); |   res.json(rows); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| // my projects/roles |  | ||||||
| r.get('/users/me/projects', requireAuth, async (req, res) => { |  | ||||||
|   const user_id = req.user?.user_id; |  | ||||||
|   if (!user_id) return res.status(401).json({ error: 'Unauthorized' }); |  | ||||||
|   const rows = await UPR.findAll({ where: { user_id } }); |  | ||||||
|   // Optionally join project names |  | ||||||
|   const projectIds = [...new Set(rows.map(r => r.project_id))]; |  | ||||||
|   const projects = await Project.findAll({ where: { project_id: projectIds } }); |  | ||||||
|   const map = new Map(projects.map(p => [p.project_id, p.project_name])); |  | ||||||
|   const result = rows.map(r => ({ project_id: r.project_id, role_name: r.role_name, project_name: map.get(r.project_id) || null })); |  | ||||||
|   res.json(result); |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
|   | |||||||
| @@ -1,100 +1,100 @@ | |||||||
| // src/routes/view.js | // FILE: backend/src/routes/view.js | ||||||
| import { Router } from 'express'; | // Saved Views: อ่านด้วย reports.view (GLOBAL); เขียนด้วย settings.manage | ||||||
| import sql from '../db/index.js'; | import { Router } from "express"; | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | import sql from "../db/index.js"; | ||||||
| import { buildScopeWhere, ownerResolvers } from '../utils/scope.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
| import PERM from '../config/permissions.js'; |  | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const OWN = ownerResolvers(sql, 'saved_views', 'id'); |  | ||||||
|  |  | ||||||
| // LIST: GET /api/view?project_id=&org_id=&shared=1 | // LIST (ทุกคนที่มี reports.view) | ||||||
| r.get('/', | r.get("/", requirePerm("reports.view"), async (req, res) => { | ||||||
|   requirePerm(PERM.savedview.read, { scope: 'global' }), |   const { project_id, shared = "1", q, limit = 50, offset = 0 } = req.query; | ||||||
|   async (req, res) => { |   const p = req.principal; | ||||||
|     const { project_id, org_id, shared, q, limit = 50, offset = 0 } = req.query; |   const cond = []; | ||||||
|  |   const params = []; | ||||||
|     const base = buildScopeWhere(req.principal, { |   // ให้เห็นของตัวเองเสมอ + shared | ||||||
|       tableAlias: 'v', |   cond.push("(v.is_shared=1 OR v.owner_user_id=?)"); | ||||||
|       orgColumn: 'v.org_id', |   params.push(p.user_id); | ||||||
|       projectColumn: 'v.project_id', |   if (project_id) { | ||||||
|       permCode: PERM.savedview.read, |     cond.push("v.project_id=?"); | ||||||
|       preferProject: true, |     params.push(Number(project_id)); | ||||||
|     }); |   } | ||||||
|  |   if (q) { | ||||||
|     const extra = []; |     cond.push("v.name LIKE ?"); | ||||||
|     const params = { ...base.params, limit: Number(limit), offset: Number(offset), my: req.principal.userId }; |     params.push(`%${q}%`); | ||||||
|     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 === "0") { | ||||||
|     if (shared === '1') extra.push('v.is_shared = 1'); |     cond.push("v.is_shared=0"); | ||||||
|     if (q)          { extra.push('(v.name LIKE :q)'); params.q = `%${q}%`; } |   } | ||||||
|  |  | ||||||
|     // ให้ผู้ใช้เห็นของตัวเองเสมอ + ของที่อยู่ใน scope |  | ||||||
|     const where = `(${base.where}) AND (v.is_shared=1 OR v.owner_user_id=:my${extra.length ? ' OR ' + extra.join(' AND ') : ''})`; |  | ||||||
|  |  | ||||||
|  |   const where = `WHERE ${cond.join(" AND ")}`; | ||||||
|   const [rows] = await sql.query( |   const [rows] = await sql.query( | ||||||
|       `SELECT v.* FROM saved_views v |     `SELECT v.* FROM saved_views v ${where} ORDER BY v.id DESC LIMIT ? OFFSET ?`, | ||||||
|        WHERE ${where} |     [...params, Number(limit), Number(offset)] | ||||||
|        ORDER BY v.id DESC |  | ||||||
|        LIMIT :limit OFFSET :offset`, params |  | ||||||
|   ); |   ); | ||||||
|   res.json(rows); |   res.json(rows); | ||||||
|   } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
| // GET by id | // GET | ||||||
| r.get('/:id', | r.get("/:id", requirePerm("reports.view"), async (req, res) => { | ||||||
|   requirePerm(PERM.savedview.read, { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
|   async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   const id = Number(req.params.id); | ||||||
|     const [[row]] = await sql.query('SELECT * FROM saved_views WHERE id=?', [id]); |   const [[row]] = await sql.query("SELECT * FROM saved_views WHERE id=?", [id]); | ||||||
|     if (!row) return res.status(404).json({ error: 'Not found' }); |   if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|     res.json(row); |   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 | // CREATE / UPDATE / DELETE (ต้องมี settings.manage) | ||||||
| r.post('/', | r.post("/", requirePerm("settings.manage"), async (req, res) => { | ||||||
|   requirePerm(PERM.savedview.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }), |   const { | ||||||
|   async (req, res) => { |     org_id, | ||||||
|     const { org_id, project_id, name, payload_json, is_shared = 0 } = req.body; |     project_id, | ||||||
|  |     name, | ||||||
|  |     payload_json, | ||||||
|  |     is_shared = 0, | ||||||
|  |   } = req.body || {}; | ||||||
|   const [rs] = await sql.query( |   const [rs] = await sql.query( | ||||||
|     `INSERT INTO saved_views (org_id, project_id, name, payload_json, is_shared, owner_user_id) |     `INSERT INTO saved_views (org_id, project_id, name, payload_json, is_shared, owner_user_id) | ||||||
|      VALUES (?,?,?,?,?,?)`, |      VALUES (?,?,?,?,?,?)`, | ||||||
|       [org_id, project_id, name, JSON.stringify(payload_json ?? {}), Number(is_shared) ? 1 : 0, req.principal.userId] |     [ | ||||||
|     ); |       org_id ?? null, | ||||||
|     res.json({ id: rs.insertId }); |       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("settings.manage"), async (req, res) => { | ||||||
| r.put('/:id', |  | ||||||
|   requirePerm(PERM.savedview.update, { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
|   async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   const id = Number(req.params.id); | ||||||
|     const { name, payload_json, is_shared } = req.body; |   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' }); |  | ||||||
|  |  | ||||||
|   await sql.query( |   await sql.query( | ||||||
|       'UPDATE saved_views SET name=?, payload_json=?, is_shared=? WHERE id=?', |     "UPDATE saved_views SET name=?, payload_json=?, is_shared=? WHERE id=?", | ||||||
|       [name, JSON.stringify(payload_json ?? {}), Number(is_shared) ? 1 : 0, id] |     [ | ||||||
|  |       name ?? null, | ||||||
|  |       JSON.stringify(payload_json ?? {}), | ||||||
|  |       Number(is_shared) ? 1 : 0, | ||||||
|  |       id, | ||||||
|  |     ] | ||||||
|   ); |   ); | ||||||
|   res.json({ ok: 1 }); |   res.json({ ok: 1 }); | ||||||
|   } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
| // DELETE | r.delete("/:id", requirePerm("settings.manage"), async (req, res) => { | ||||||
| r.delete('/:id', |  | ||||||
|   requirePerm(PERM.savedview.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }), |  | ||||||
|   async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   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 }); |   res.json({ ok: 1 }); | ||||||
|   } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
|   | |||||||
| @@ -1,29 +1,23 @@ | |||||||
| // src/routes/views.js (ESM) | // FILE: backend/src/routes/views.js | ||||||
| import { Router } from 'express'; | // จำกัดเฉพาะแอดมินระบบ: settings.manage | ||||||
| import sql from '../db/index.js'; | import { Router } from "express"; | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | import sql from "../db/index.js"; | ||||||
| import PERM from '../config/permissions.js'; | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
| const DB_NAME = process.env.DB_NAME || 'dms_db'; | const DB_NAME = process.env.DB_NAME || "dms_db"; | ||||||
|  |  | ||||||
| // LIST views | r.get("/", requirePerm("settings.manage"), async (_req, res) => { | ||||||
| r.get('/', |  | ||||||
|   requirePerm(PERM.viewdef.read, { scope: 'global' }), |  | ||||||
|   async (req, res) => { |  | ||||||
|   const [rows] = await sql.query( |   const [rows] = await sql.query( | ||||||
|     `SELECT TABLE_SCHEMA AS db, TABLE_NAME AS view_name |     `SELECT TABLE_SCHEMA AS db, TABLE_NAME AS view_name | ||||||
|        FROM information_schema.VIEWS |        FROM information_schema.VIEWS | ||||||
|        WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME`, [DB_NAME] |       WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME`, | ||||||
|  |     [DB_NAME] | ||||||
|   ); |   ); | ||||||
|   res.json(rows); |   res.json(rows); | ||||||
|   } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
| // GET view definition | r.get("/:view_name", requirePerm("settings.manage"), async (req, res) => { | ||||||
| r.get('/:view_name', |  | ||||||
|   requirePerm(PERM.viewdef.read, { scope: 'global' }), |  | ||||||
|   async (req, res) => { |  | ||||||
|   const viewName = req.params.view_name; |   const viewName = req.params.view_name; | ||||||
|   const [[row]] = await sql.query( |   const [[row]] = await sql.query( | ||||||
|     `SELECT VIEW_DEFINITION AS definition |     `SELECT VIEW_DEFINITION AS definition | ||||||
| @@ -31,9 +25,8 @@ r.get('/:view_name', | |||||||
|       WHERE TABLE_SCHEMA=? AND TABLE_NAME=?`, |       WHERE TABLE_SCHEMA=? AND TABLE_NAME=?`, | ||||||
|     [DB_NAME, viewName] |     [DB_NAME, viewName] | ||||||
|   ); |   ); | ||||||
|     if (!row) return res.status(404).json({ error: 'Not found' }); |   if (!row) return res.status(404).json({ error: "Not found" }); | ||||||
|   res.json({ view: viewName, definition: row.definition }); |   res.json({ view: viewName, definition: row.definition }); | ||||||
|   } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
|   | |||||||
| @@ -1,50 +1,55 @@ | |||||||
| import { Router } from 'express'; | // FILE: backend/src/routes/volumes.js | ||||||
| import sql from '../db/index.js'; | // Master data: volumes | ||||||
| import { requirePerm } from '../middleware/requirePerm.js'; | // - Read:  organizations.view (GLOBAL) | ||||||
| import PERM from '../config/permissions.js'; | // - Write: settings.manage     (GLOBAL) | ||||||
|  |  | ||||||
|  | import { Router } from "express"; | ||||||
|  | import sql from "../db/index.js"; | ||||||
|  | import { requirePerm } from "../middleware/requirePerm.js"; | ||||||
|  |  | ||||||
| const r = Router(); | const r = Router(); | ||||||
|  |  | ||||||
|  | // LIST | ||||||
| r.get('/', | r.get("/", requirePerm("organizations.view"), async (_req, res) => { | ||||||
| requirePerm(PERM.volume.read, { scope: 'global' }), |   const [rows] = await sql.query( | ||||||
| async (req, res) => { |     "SELECT volume_id, volume_code, volume_name FROM volumes ORDER BY volume_code ASC" | ||||||
| const [rows] = await sql.query('SELECT * FROM volumes ORDER BY volume_id DESC'); |   ); | ||||||
|   res.json(rows); |   res.json(rows); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // CREATE | ||||||
|  | r.post("/", requirePerm("settings.manage"), async (req, res) => { | ||||||
|  |   const { volume_code, volume_name } = req.body || {}; | ||||||
|  |   if (!volume_code || !volume_name) { | ||||||
|  |     return res | ||||||
|  |       .status(400) | ||||||
|  |       .json({ error: "volume_code and volume_name required" }); | ||||||
|   } |   } | ||||||
|  |   const [rs] = await sql.query( | ||||||
|  |     "INSERT INTO volumes (volume_code, volume_name) VALUES (?, ?)", | ||||||
|  |     [volume_code, volume_name] | ||||||
|   ); |   ); | ||||||
|  |   res.status(201).json({ volume_id: rs.insertId }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // UPDATE | ||||||
| r.post('/', | r.put("/:id", requirePerm("settings.manage"), async (req, res) => { | ||||||
| requirePerm(PERM.volume.create, { scope: 'global' }), |  | ||||||
| async (req, res) => { |  | ||||||
| const { volume_code, volume_name } = req.body; |  | ||||||
| const [rs] = await sql.query('INSERT INTO volumes (volume_code, volume_name) VALUES (?,?)', [volume_code, volume_name]); |  | ||||||
| res.json({ volume_id: rs.insertId }); |  | ||||||
| } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
|  |  | ||||||
| r.put('/:id', |  | ||||||
| requirePerm(PERM.volume.update, { scope: 'global' }), |  | ||||||
| async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   const id = Number(req.params.id); | ||||||
| const { volume_name } = req.body; |   const { volume_name } = req.body || {}; | ||||||
| await sql.query('UPDATE volumes SET volume_name=? WHERE volume_id=?', [volume_name, id]); |   if (!volume_name) | ||||||
| res.json({ ok: 1 }); |     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 }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // DELETE | ||||||
| r.delete('/:id', | r.delete("/:id", requirePerm("settings.manage"), async (req, res) => { | ||||||
| requirePerm(PERM.volume.delete, { scope: 'global' }), |  | ||||||
| async (req, res) => { |  | ||||||
|   const id = Number(req.params.id); |   const id = Number(req.params.id); | ||||||
| await sql.query('DELETE FROM volumes WHERE volume_id=?', [id]); |   await sql.query("DELETE FROM volumes WHERE volume_id=?", [id]); | ||||||
| res.json({ ok: 1 }); |   res.json({ ok: true }); | ||||||
| } | }); | ||||||
| ); |  | ||||||
|  |  | ||||||
|  |  | ||||||
| export default r; | export default r; | ||||||
							
								
								
									
										17
									
								
								backend/src/utils/cookie.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								backend/src/utils/cookie.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | // FILE: backend/src/utils/cookie.js | ||||||
|  | export const cookieOpts = (maxAgeMs) => { | ||||||
|  |   const isProd = process.env.NODE_ENV === "production"; | ||||||
|  |   const opts = { | ||||||
|  |     httpOnly: true, | ||||||
|  |     secure: true, // หลัง Nginx/HTTPS | ||||||
|  |     sameSite: "none", // ส่งข้าม subdomain ได้ | ||||||
|  |     path: "/", | ||||||
|  |     maxAge: maxAgeMs, | ||||||
|  |   }; | ||||||
|  |   if (process.env.COOKIE_DOMAIN) opts.domain = process.env.COOKIE_DOMAIN; // เช่น .np-dms.work | ||||||
|  |   if (!isProd && process.env.ALLOW_INSECURE_COOKIE === "1") { | ||||||
|  |     opts.secure = false; | ||||||
|  |     opts.sameSite = "lax"; | ||||||
|  |   } | ||||||
|  |   return opts; | ||||||
|  | }; | ||||||
							
								
								
									
										31
									
								
								backend/src/utils/jwt.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								backend/src/utils/jwt.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | // FILE: backend/src/utils/jwt.js | ||||||
|  | import jwt from "jsonwebtoken"; | ||||||
|  |  | ||||||
|  | const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || "dev_access_secret"; | ||||||
|  | const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || "dev_refresh_secret"; | ||||||
|  |  | ||||||
|  | export const ACCESS_TTL_MS = parseInt( | ||||||
|  |   process.env.ACCESS_TTL_MS || `${15 * 60 * 1000}`, | ||||||
|  |   10 | ||||||
|  | ); // 15 นาที | ||||||
|  | export const REFRESH_TTL_MS = parseInt( | ||||||
|  |   process.env.REFRESH_TTL_MS || `${7 * 24 * 60 * 60 * 1000}`, | ||||||
|  |   10 | ||||||
|  | ); // 7 วัน | ||||||
|  |  | ||||||
|  | export function signAccessToken(payload) { | ||||||
|  |   return jwt.sign(payload, ACCESS_SECRET, { | ||||||
|  |     expiresIn: Math.floor(ACCESS_TTL_MS / 1000), | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | export function signRefreshToken(payload) { | ||||||
|  |   return jwt.sign(payload, REFRESH_SECRET, { | ||||||
|  |     expiresIn: Math.floor(REFRESH_TTL_MS / 1000), | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | export function verifyAccessToken(token) { | ||||||
|  |   return jwt.verify(token, ACCESS_SECRET, { clockTolerance: 10 }); // เผื่อเวลา QNAP คลาด | ||||||
|  | } | ||||||
|  | export function verifyRefreshToken(token) { | ||||||
|  |   return jwt.verify(token, REFRESH_SECRET, { clockTolerance: 10 }); | ||||||
|  | } | ||||||
| @@ -1,4 +1,14 @@ | |||||||
| import bcrypt from 'bcrypt'; | // FILE: backend/src/utils/passwords.js | ||||||
|  | // Password hashing and verification utilities | ||||||
|  | // - Uses bcrypt for secure password hashing | ||||||
|  | // - Provides hashPassword(plain) and verifyPassword(plain, hash) functions | ||||||
|  | // - hashPassword returns a promise that resolves to the hashed password | ||||||
|  | // - verifyPassword returns a promise that resolves to true/false | ||||||
|  | // - Uses 10 salt rounds for hashing | ||||||
|  | // - Assumes bcrypt package is installed | ||||||
|  | // - Suitable for user authentication systems | ||||||
|  | // - Can be used in user registration and login flows | ||||||
|  | import bcrypt from "bcrypt"; | ||||||
| export async function hashPassword(plain) { | export async function hashPassword(plain) { | ||||||
|   const saltRounds = 10; |   const saltRounds = 10; | ||||||
|   return bcrypt.hash(plain, saltRounds); |   return bcrypt.hash(plain, saltRounds); | ||||||
|   | |||||||
| @@ -1,40 +1,58 @@ | |||||||
| // src/utils/rbac.js | // FILE: backend/src/utils/rbac.js | ||||||
| import sql from '../db/index.js'; | // 03.2 2) เพิ่มตัวช่วย RBAC (ใหม่) | ||||||
|  | // Role-Based Access Control (RBAC) utilities | ||||||
|  | // - loadPrincipal(userId) to load user's roles, permissions, orgs, projects | ||||||
|  | // - canPerform(principal, permCode, {scope, orgId, projectId}) to check permission | ||||||
|  | // - Uses raw SQL queries via db/index.js | ||||||
|  | // - Permissions can be global, org-scoped, or project-scoped | ||||||
|  | // - Admin roles have special handling for org/project scope | ||||||
|  | // - SUPER_ADMIN bypasses all checks | ||||||
|  |  | ||||||
|  | import sql from "../db/index.js"; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * โหลด principal (บทบาท, องค์กร, โปรเจกต์) ให้ผู้ใช้ |  * โหลด principal (บทบาท, องค์กร, โปรเจกต์) ให้ผู้ใช้ | ||||||
|  */ |  */ | ||||||
| export async function loadPrincipal(userId) { | export async function loadPrincipal(userId) { | ||||||
| const [rolesRows] = await sql.query(/*sql*/` |   const [rolesRows] = await sql.query( | ||||||
|  |     /*sql*/ ` | ||||||
|     SELECT ur.user_id, r.role_code, ur.org_id, ur.project_id |     SELECT ur.user_id, r.role_code, ur.org_id, ur.project_id | ||||||
|     FROM user_roles ur |     FROM user_roles ur | ||||||
|     JOIN roles r ON r.role_id = ur.role_id |     JOIN roles r ON r.role_id = ur.role_id | ||||||
|     WHERE ur.user_id = ? |     WHERE ur.user_id = ? | ||||||
| `, [userId]); |   `, | ||||||
|  |     [userId] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const [permRows] = await sql.query( | ||||||
| const [permRows] = await sql.query(/*sql*/` |     /*sql*/ ` | ||||||
|     SELECT ur.user_id, r.role_code, p.permission_code, ur.org_id, ur.project_id |     SELECT ur.user_id, r.role_code, p.permission_code, ur.org_id, ur.project_id | ||||||
|     FROM user_roles ur |     FROM user_roles ur | ||||||
|     JOIN roles r ON r.role_id = ur.role_id |     JOIN roles r ON r.role_id = ur.role_id | ||||||
|     JOIN role_permissions rp ON rp.role_id = r.role_id |     JOIN role_permissions rp ON rp.role_id = r.role_id | ||||||
|     JOIN permissions p ON p.permission_id = rp.permission_id |     JOIN permissions p ON p.permission_id = rp.permission_id | ||||||
|     WHERE ur.user_id = ? |     WHERE ur.user_id = ? | ||||||
| `, [userId]); |   `, | ||||||
|  |     [userId] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const roleCodes = new Set(rolesRows.map((r) => r.role_code)); | ||||||
| const roleCodes = new Set(rolesRows.map(r => r.role_code)); |   const isSuperAdmin = roleCodes.has("SUPER_ADMIN"); | ||||||
| const isSuperAdmin = roleCodes.has('SUPER_ADMIN'); |  | ||||||
|  |  | ||||||
|   // set องค์กรที่ผู้ใช้อยู่ (ใช้สำหรับ ADMIN scope) |   // set องค์กรที่ผู้ใช้อยู่ (ใช้สำหรับ ADMIN scope) | ||||||
| const orgIds = new Set(rolesRows.filter(r => r.org_id).map(r => r.org_id)); |   const orgIds = new Set( | ||||||
| const projectIds = new Set(rolesRows.filter(r => r.project_id).map(r => r.project_id)); |     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 } |   // map สิทธิเป็น: permCode -> { orgIds:Set, projectIds:Set } | ||||||
|   const perms = new Map(); |   const perms = new Map(); | ||||||
|   for (const r of permRows) { |   for (const r of permRows) { | ||||||
|     const key = r.permission_code; |     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.org_id) perms.get(key).orgIds.add(r.org_id); | ||||||
|     if (r.project_id) perms.get(key).projectIds.add(r.project_id); |     if (r.project_id) perms.get(key).projectIds.add(r.project_id); | ||||||
|   } |   } | ||||||
| @@ -55,30 +73,35 @@ return { | |||||||
|  * - ADMIN: ผ่านได้ "ภายใน org ของตัวเอง" เท่านั้น |  * - ADMIN: ผ่านได้ "ภายใน org ของตัวเอง" เท่านั้น | ||||||
|  * - อื่น ๆ: ต้องถือ permission_code และเข้า scope ที่ถูกต้อง |  * - อื่น ๆ: ต้องถือ permission_code และเข้า scope ที่ถูกต้อง | ||||||
|  */ |  */ | ||||||
| export function canPerform(principal, permCode, { scope = 'global', orgId = null, projectId = null } = {}) { | export function canPerform( | ||||||
|  |   principal, | ||||||
|  |   permCode, | ||||||
|  |   { scope = "global", orgId = null, projectId = null } = {} | ||||||
|  | ) { | ||||||
|   if (!principal) return false; |   if (!principal) return false; | ||||||
|   if (principal.isSuperAdmin) return true; |   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 (!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); |     const entry = principal.perms.get(permCode); | ||||||
|     return !!entry && (entry.orgIds.has(orgId) || entry.orgIds.size === 0); |     return !!entry && (entry.orgIds.has(orgId) || entry.orgIds.size === 0); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   if (scope === "project") { | ||||||
| if (scope === 'project') { |  | ||||||
|     if (!projectId) return false; |     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); |     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; |   return false; | ||||||
| } | } | ||||||
| @@ -1,3 +1,14 @@ | |||||||
|  | // FILE: backend/src/utils/scope.js | ||||||
|  | // 03.2 5) เพิ่ม utils/scope.js (ใหม่) | ||||||
|  | // - ใช้ร่วมกับ requirePerm() และ loadPrincipal() | ||||||
|  | // - สำหรับสร้าง SQL WHERE clause ในการ list entities ตาม scope ของผู้ใช้ | ||||||
|  | // Scope and permission utilities | ||||||
|  | // - Functions to build SQL WHERE clauses based on user principal and permissions | ||||||
|  | // - Used for filtering list queries according to user's | ||||||
|  | //   roles, permissions, and associated orgs/projects | ||||||
|  | // - Works with rbac.js loadPrincipal() output | ||||||
|  | // - Supports SUPER_ADMIN, ADMIN, and scoped permissions | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * สร้าง WHERE fragment + params สำหรับ list ตาม principal |  * สร้าง WHERE fragment + params สำหรับ list ตาม principal | ||||||
|  * - SUPER_ADMIN: ไม่จำกัด |  * - SUPER_ADMIN: ไม่จำกัด | ||||||
| @@ -12,17 +23,18 @@ | |||||||
|  *   permCode: permission_code ที่ใช้สำหรับ read list (เช่น 'correspondence.read') |  *   permCode: permission_code ที่ใช้สำหรับ read list (เช่น 'correspondence.read') | ||||||
|  *   preferProject: true -> บังคับต้อง match project scope ก่อน (ถ้ามี) |  *   preferProject: true -> บังคับต้อง match project scope ก่อน (ถ้ามี) | ||||||
|  */ |  */ | ||||||
| export function buildScopeWhere(principal, { | export function buildScopeWhere( | ||||||
|   tableAlias, orgColumn, projectColumn, permCode, preferProject = false, |   principal, | ||||||
| }) { |   { tableAlias, orgColumn, projectColumn, permCode, preferProject = false } | ||||||
|   if (principal.isSuperAdmin) return { where: '1=1', params: {} }; | ) { | ||||||
|  |   if (principal.isSuperAdmin) return { where: "1=1", params: {} }; | ||||||
|  |  | ||||||
|   const perm = principal.perms.get(permCode); |   const perm = principal.perms.get(permCode); | ||||||
|   const orgIds = new Set(principal.orgIds); |   const orgIds = new Set(principal.orgIds); | ||||||
|   const projectIds = new Set(principal.projectIds); |   const projectIds = new Set(principal.projectIds); | ||||||
|  |  | ||||||
|   // กรณี ADMIN: ให้ดูภายใน org/project ตัวเองได้ทั้งหมด (แต่ต้องถือ permCode) |   // กรณี ADMIN: ให้ดูภายใน org/project ตัวเองได้ทั้งหมด (แต่ต้องถือ permCode) | ||||||
|   if (principal.roleCodes.has('ADMIN') && perm) { |   if (principal.roleCodes.has("ADMIN") && perm) { | ||||||
|     const orgList = [...orgIds]; |     const orgList = [...orgIds]; | ||||||
|     const prjList = [...projectIds]; |     const prjList = [...projectIds]; | ||||||
|     if (preferProject && prjList.length > 0) { |     if (preferProject && prjList.length > 0) { | ||||||
| @@ -38,11 +50,11 @@ export function buildScopeWhere(principal, { | |||||||
|       }; |       }; | ||||||
|     } |     } | ||||||
|     // ถ้าไม่มี mapping เลย ให้ไม่เห็นอะไร |     // ถ้าไม่มี mapping เลย ให้ไม่เห็นอะไร | ||||||
|     return { where: '1=0', params: {} }; |     return { where: "1=0", params: {} }; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // บทบาทอื่น: อิงตาม perm scope |   // บทบาทอื่น: อิงตาม perm scope | ||||||
|   if (!perm) return { where: '1=0', params: {} }; |   if (!perm) return { where: "1=0", params: {} }; | ||||||
|  |  | ||||||
|   const permOrg = [...perm.orgIds]; |   const permOrg = [...perm.orgIds]; | ||||||
|   const permPrj = [...perm.projectIds]; |   const permPrj = [...perm.projectIds]; | ||||||
| @@ -55,25 +67,31 @@ export function buildScopeWhere(principal, { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   // ถ้า perm ไม่มี scope ผูก (global grant) ให้ผ่านทั้งหมด |   // ถ้า perm ไม่มี scope ผูก (global grant) ให้ผ่านทั้งหมด | ||||||
|   return { where: '1=1', params: {} }; |   return { where: "1=1", params: {} }; | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * owner resolvers: อ่าน org_id/project_id จาก DB ด้วย id |  * owner resolvers: อ่าน org_id/project_id จาก DB ด้วย id | ||||||
|  * ใช้กับ requirePerm(getOrgId/getProjectId) เสมอ |  * ใช้กับ requirePerm(getOrgId/getProjectId) เสมอ | ||||||
|  */ |  */ | ||||||
| export function ownerResolvers(sql, mainTable, idColumn = 'id') { | export function ownerResolvers(sql, mainTable, idColumn = "id") { | ||||||
|   return { |   return { | ||||||
|     async getOrgIdById(req) { |     async getOrgIdById(req) { | ||||||
|       const id = Number(req.params.id ?? req.body?.id); |       const id = Number(req.params.id ?? req.body?.id); | ||||||
|       if (!id) return null; |       if (!id) return null; | ||||||
|       const [[row]] = await sql.query(`SELECT org_id FROM ${mainTable} WHERE ${idColumn}=?`, [id]); |       const [[row]] = await sql.query( | ||||||
|  |         `SELECT org_id FROM ${mainTable} WHERE ${idColumn}=?`, | ||||||
|  |         [id] | ||||||
|  |       ); | ||||||
|       return row?.org_id ?? null; |       return row?.org_id ?? null; | ||||||
|     }, |     }, | ||||||
|     async getProjectIdById(req) { |     async getProjectIdById(req) { | ||||||
|       const id = Number(req.params.id ?? req.body?.id); |       const id = Number(req.params.id ?? req.body?.id); | ||||||
|       if (!id) return null; |       if (!id) return null; | ||||||
|       const [[row]] = await sql.query(`SELECT project_id FROM ${mainTable} WHERE ${idColumn}=?`, [id]); |       const [[row]] = await sql.query( | ||||||
|  |         `SELECT project_id FROM ${mainTable} WHERE ${idColumn}=?`, | ||||||
|  |         [id] | ||||||
|  |       ); | ||||||
|       return row?.project_id ?? null; |       return row?.project_id ?? null; | ||||||
|     }, |     }, | ||||||
|   }; |   }; | ||||||
|   | |||||||
							
								
								
									
										15
									
								
								backend/tests/health.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								backend/tests/health.test.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | import app from "../src/index.js"; // สมมติว่าคุณ export app จาก src/index.js | ||||||
|  | import request from "supertest"; | ||||||
|  |  | ||||||
|  | // ปิด server หลังจากเทสเสร็จ | ||||||
|  | afterAll((done) => { | ||||||
|  |   app.server.close(done); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | describe("GET /health", () => { | ||||||
|  |   it("should respond with 200 OK and a health message", async () => { | ||||||
|  |     const response = await request(app).get("/health"); | ||||||
|  |     expect(response.statusCode).toBe(200); | ||||||
|  |     expect(response.text).toContain("Backend is healthy"); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @@ -1,8 +0,0 @@ | |||||||
| { |  | ||||||
| 	"folders": [ |  | ||||||
| 		{ |  | ||||||
| 			"path": "." |  | ||||||
| 		} |  | ||||||
| 	], |  | ||||||
| 	"settings": {} |  | ||||||
| } |  | ||||||
							
								
								
									
										2
									
								
								docker-backend-build.yml
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										2
									
								
								docker-backend-build.yml
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							| @@ -16,7 +16,7 @@ services: | |||||||
|       target: prod |       target: prod | ||||||
|     image: dms-backend:prod |     image: dms-backend:prod | ||||||
|     command: ["true"] |     command: ["true"] | ||||||
| # docker compose -f docker-backend-build.yml build --no-cache | # docker compose -f docker-backend-build.yml build --no-cache 2>&1 | tee backend_build.log | ||||||
| # ***** สำหรับ build บน server เอา ## ออก ***** | # ***** สำหรับ build บน server เอา ## ออก ***** | ||||||
| # สำหรับ build บน local | # สำหรับ build บน local | ||||||
| # cd backend | # cd backend | ||||||
|   | |||||||
							
								
								
									
										74
									
								
								docker-compose.yml
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										74
									
								
								docker-compose.yml
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							| @@ -1,4 +1,4 @@ | |||||||
| # DMS Container v0_6_0 | # DMS Container v0_7_0 | ||||||
| # version: "3.8" | # version: "3.8" | ||||||
| x-restart: &restart_policy | x-restart: &restart_policy | ||||||
|   restart: unless-stopped |   restart: unless-stopped | ||||||
| @@ -25,10 +25,10 @@ services: | |||||||
|     deploy: |     deploy: | ||||||
|       resources: |       resources: | ||||||
|         limits: |         limits: | ||||||
|           cpus: '2.0' |           cpus: "2.0" | ||||||
|           memory: 4G |           memory: 4G | ||||||
|         reservations: |         reservations: | ||||||
|           cpus: '0.5' |           cpus: "0.5" | ||||||
|           memory: 1G |           memory: 1G | ||||||
|     environment: |     environment: | ||||||
|       MYSQL_ROOT_PASSWORD: "Center#2025" |       MYSQL_ROOT_PASSWORD: "Center#2025" | ||||||
| @@ -45,7 +45,8 @@ services: | |||||||
|       - "/share/Container/dms/mariadb/init:/docker-entrypoint-initdb.d:ro" |       - "/share/Container/dms/mariadb/init:/docker-entrypoint-initdb.d:ro" | ||||||
|       - "/share/dms-data/mariadb/backup:/backup" |       - "/share/dms-data/mariadb/backup:/backup" | ||||||
|     healthcheck: |     healthcheck: | ||||||
|       test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -pCenter#2025 || exit 1"] |       test: | ||||||
|  |         ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -pCenter#2025 || exit 1"] | ||||||
|       interval: 10s |       interval: 10s | ||||||
|       timeout: 5s |       timeout: 5s | ||||||
|       retries: 15 |       retries: 15 | ||||||
| @@ -56,16 +57,16 @@ services: | |||||||
|     container_name: dms_backend |     container_name: dms_backend | ||||||
|     stdin_open: true |     stdin_open: true | ||||||
|     tty: true |     tty: true | ||||||
|     user: "node"   |     #user: "node" | ||||||
|     # user: "1000:1000" |     user: "1000:1000" | ||||||
|     working_dir: /app |     working_dir: /app | ||||||
|     deploy: |     deploy: | ||||||
|       resources: |       resources: | ||||||
|         limits: |         limits: | ||||||
|           cpus: '2.0' |           cpus: "2.0" | ||||||
|           memory: 1G |           memory: 1G | ||||||
|         reservations: |         reservations: | ||||||
|           cpus: '0.25' |           cpus: "0.25" | ||||||
|           memory: 256M |           memory: 256M | ||||||
|     environment: |     environment: | ||||||
|       TZ: "Asia/Bangkok" |       TZ: "Asia/Bangkok" | ||||||
| @@ -80,11 +81,16 @@ services: | |||||||
|       DB_USER: "center" |       DB_USER: "center" | ||||||
|       DB_PASSWORD: "Center#2025" |       DB_PASSWORD: "Center#2025" | ||||||
|       DB_NAME: "dms" |       DB_NAME: "dms" | ||||||
|       JWT_SECRET: "8b0df02e4aee9f9f79a4f2d8ba77b0b82c1ee3446b68cb0bae94ab54d60f8d9e" |       JWT_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca" | ||||||
|  |       JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca" | ||||||
|  |       JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c" | ||||||
|  |       ACCESS_TTL_MS: "900000" | ||||||
|  |       REFRESH_TTL_MS: "604800000" | ||||||
|       JWT_EXPIRES_IN: "12h" |       JWT_EXPIRES_IN: "12h" | ||||||
|       PASSWORD_SALT_ROUNDS: "10" |       PASSWORD_SALT_ROUNDS: "10" | ||||||
|       FRONTEND_ORIGIN: "https://lcbp3.mycloudnas.com" |       FRONTEND_ORIGIN: "https://lcbp3.np-dms.work" | ||||||
|       CORS_ORIGINS: "https://backend.np-dms.work,http://localhost:3000,http://127.0.0.1:3000" |       CORS_ORIGINS: "https://lcbp3.np-dms.work,http://localhost:3000,http://127.0.0.1:3000" | ||||||
|  |       COOKIE_DOMAIN: ".np-dms.work" | ||||||
|       RATE_LIMIT_WINDOW_MS: "900000" |       RATE_LIMIT_WINDOW_MS: "900000" | ||||||
|       RATE_LIMIT_MAX: "200" |       RATE_LIMIT_MAX: "200" | ||||||
|       BACKEND_LOG_DIR: "/app/logs" |       BACKEND_LOG_DIR: "/app/logs" | ||||||
| @@ -114,22 +120,27 @@ services: | |||||||
|     container_name: dms_frontend |     container_name: dms_frontend | ||||||
|     stdin_open: true |     stdin_open: true | ||||||
|     tty: true |     tty: true | ||||||
|     user: "node" |     # user: "node" | ||||||
|     # user: "1000:1000" |     user: "1000:1000" | ||||||
|     working_dir: /app |     working_dir: /app | ||||||
|     deploy: |     deploy: | ||||||
|       resources: |       resources: | ||||||
|         limits: |         limits: | ||||||
|           cpus: '1.0' |           cpus: "2.0" | ||||||
|           memory: 1G |           memory: 2G | ||||||
|     environment: |     environment: | ||||||
|       TZ: "Asia/Bangkok" |       TZ: "Asia/Bangkok" | ||||||
|       NODE_ENV: "development" |       NODE_ENV: "development" | ||||||
|       NEXT_PUBLIC_API_BASE: "/api" |       # NEXT_PUBLIC_API_BASE: "/api" | ||||||
|       CHOKIDAR_USEPOLLING: "1" |       CHOKIDAR_USEPOLLING: "1" | ||||||
|       WATCHPACK_POLLING: "true" |       WATCHPACK_POLLING: "true" | ||||||
|       NEXT_PUBLIC_API_BASE=https: "//lcbp3.np-dms.work/api" |       NEXT_PUBLIC_API_BASE: "https://lcbp3.np-dms.work" | ||||||
|  |       NEXT_PUBLIC_AUTH_MODE: "cookie" | ||||||
|  |       NEXT_PUBLIC_DEBUG_AUTH: "1" | ||||||
|       NEXT_TELEMETRY_DISABLED: "1" |       NEXT_TELEMETRY_DISABLED: "1" | ||||||
|  |       INTERNAL_API_BASE: "http://backend:3001" | ||||||
|  |       JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca" | ||||||
|  |       JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c" | ||||||
|     expose: |     expose: | ||||||
|       - "3000" |       - "3000" | ||||||
|     networks: [dmsnet] |     networks: [dmsnet] | ||||||
| @@ -144,7 +155,11 @@ services: | |||||||
|       backend: |       backend: | ||||||
|         condition: service_healthy |         condition: service_healthy | ||||||
|     healthcheck: |     healthcheck: | ||||||
|       test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] |       test: | ||||||
|  |         [ | ||||||
|  |           "CMD-SHELL", | ||||||
|  |           'wget -qO- http://127.0.0.1:3000/health | grep -q ''"ok":true''', | ||||||
|  |         ] | ||||||
|       interval: 15s |       interval: 15s | ||||||
|       timeout: 5s |       timeout: 5s | ||||||
|       retries: 30 |       retries: 30 | ||||||
| @@ -157,7 +172,7 @@ services: | |||||||
|     deploy: |     deploy: | ||||||
|       resources: |       resources: | ||||||
|         limits: |         limits: | ||||||
|           cpus: '0.25' |           cpus: "0.25" | ||||||
|           memory: 256M |           memory: 256M | ||||||
|     environment: |     environment: | ||||||
|       TZ: "Asia/Bangkok" |       TZ: "Asia/Bangkok" | ||||||
| @@ -192,10 +207,10 @@ services: | |||||||
|     deploy: |     deploy: | ||||||
|       resources: |       resources: | ||||||
|         limits: |         limits: | ||||||
|           cpus: '1.0' |           cpus: "1.0" | ||||||
|           memory: 2G |           memory: 2G | ||||||
|         reservations: |         reservations: | ||||||
|           cpus: '0.5' |           cpus: "0.5" | ||||||
|           memory: 1G |           memory: 1G | ||||||
|     environment: |     environment: | ||||||
|       POSTGRES_DB: "n8n" |       POSTGRES_DB: "n8n" | ||||||
| @@ -218,7 +233,7 @@ services: | |||||||
|     deploy: |     deploy: | ||||||
|       resources: |       resources: | ||||||
|         limits: |         limits: | ||||||
|           cpus: '0.25' |           cpus: "0.25" | ||||||
|           memory: 256M |           memory: 256M | ||||||
|     environment: |     environment: | ||||||
|       PGADMIN_DEFAULT_EMAIL: "center.pslcp3@gmail.com" |       PGADMIN_DEFAULT_EMAIL: "center.pslcp3@gmail.com" | ||||||
| @@ -246,22 +261,23 @@ services: | |||||||
|     deploy: |     deploy: | ||||||
|       resources: |       resources: | ||||||
|         limits: |         limits: | ||||||
|           cpus: '1.5' |           cpus: "1.5" | ||||||
|           memory: 2G |           memory: 2G | ||||||
|         reservations: |         reservations: | ||||||
|           cpus: '0.25' |           cpus: "0.25" | ||||||
|           memory: 512M |           memory: 512M | ||||||
|     environment: |     environment: | ||||||
|       TZ: "Asia/Bangkok" |       TZ: "Asia/Bangkok" | ||||||
|       NODE_ENV: "production" |       NODE_ENV: "production" | ||||||
|       N8N_PATH: "/n8n/" |       N8N_PATH: "/n8n/" | ||||||
|       N8N_PUBLIC_URL: "https://n8n.np-dms.work/" |       N8N_PUBLIC_URL: "https://n8n.np-dms.work/" | ||||||
|       WEBHOOK_URL: "https://ln8n.np-dms.work/" |       WEBHOOK_URL: "https://n8n.np-dms.work/" | ||||||
|       N8N_EDITOR_BASE_URL: "https://n8n.np-dms.work/" |       N8N_EDITOR_BASE_URL: "https://n8n.np-dms.work/" | ||||||
|       N8N_PROTOCOL: "https" |       N8N_PROTOCOL: "https" | ||||||
|       N8N_HOST: "n8n.np-dms.work" |       N8N_HOST: "n8n.np-dms.work" | ||||||
|       N8N_PORT: "5678" |       N8N_PORT: "5678" | ||||||
|       N8N_PROXY_HOPS: "1" |       N8N_PROXY_HOPS: "1" | ||||||
|  |       N8N_DIAGNOSTICS_ENABLED: "false" | ||||||
|       N8N_SECURE_COOKIE: "true" |       N8N_SECURE_COOKIE: "true" | ||||||
|       N8N_ENCRYPTION_KEY: "9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI" |       N8N_ENCRYPTION_KEY: "9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI" | ||||||
|       N8N_BASIC_AUTH_ACTIVE: "true" |       N8N_BASIC_AUTH_ACTIVE: "true" | ||||||
| @@ -300,7 +316,7 @@ services: | |||||||
|     deploy: |     deploy: | ||||||
|       resources: |       resources: | ||||||
|         limits: |         limits: | ||||||
|           cpus: '1.0'   # 50% CPU |           cpus: "1.0" # 50% CPU | ||||||
|           memory: 512M |           memory: 512M | ||||||
|     ports: |     ports: | ||||||
|       - "80:80" # HTTP |       - "80:80" # HTTP | ||||||
| @@ -319,8 +335,8 @@ services: | |||||||
|     depends_on: |     depends_on: | ||||||
|       backend: |       backend: | ||||||
|         condition: service_healthy |         condition: service_healthy | ||||||
|       frontend: |       # frontend: | ||||||
|         condition: service_healthy |       #  condition: service_healthy | ||||||
|       phpmyadmin: |       phpmyadmin: | ||||||
|         condition: service_started |         condition: service_started | ||||||
|       n8n: |       n8n: | ||||||
| @@ -336,7 +352,7 @@ services: | |||||||
|     deploy: |     deploy: | ||||||
|       resources: |       resources: | ||||||
|         limits: |         limits: | ||||||
|           cpus: '0.50'   # 50% CPU |           cpus: "0.50" # 50% CPU | ||||||
|           memory: 128M |           memory: 128M | ||||||
|     networks: [dmsnet] |     networks: [dmsnet] | ||||||
|     volumes: |     volumes: | ||||||
|   | |||||||
							
								
								
									
										26
									
								
								docker-frontend-build.yml
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										26
									
								
								docker-frontend-build.yml
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							| @@ -1,31 +1,35 @@ | |||||||
| services: | services: | ||||||
|   frontend_dev_image: |   frontend_dev_image: | ||||||
|     build: |     build: | ||||||
|       # context: /share/Container/dms/frontend |       context: /share/Container/dms/frontend | ||||||
|       context: ./frontend |       # context: ./frontend | ||||||
|       dockerfile: Dockerfile |       dockerfile: Dockerfile | ||||||
|       target: dev |       target: dev | ||||||
|  |       args: | ||||||
|  |         - NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work | ||||||
|  |         - NODE_ENV=development | ||||||
|     image: dms-frontend:dev |     image: dms-frontend:dev | ||||||
|     command: ["true"] |     command: ["true"] | ||||||
|          |          | ||||||
|   frontend_prod_image: |   frontend_prod_image: | ||||||
|     build: |     build: | ||||||
|       ## context: /share/Container/dms/frontend |       context: /share/Container/dms/frontend | ||||||
|       context: ./frontend |       # context: ./frontend | ||||||
|       args: |       args: | ||||||
|         - NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work |         - NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work | ||||||
|         - NODE_ENV=production #added |         - NODE_ENV=production | ||||||
|       dockerfile: Dockerfile |       dockerfile: Dockerfile | ||||||
|       target: prod |       target: prod | ||||||
|       ## environment: |  | ||||||
|       ##  - NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work |  | ||||||
|     image: dms-frontend:prod |     image: dms-frontend:prod | ||||||
|     command: ["true"] |     command: ["true"] | ||||||
|     environment: |    | ||||||
|       - NEXT_PUBLIC_API_BASE=https://lcbp3.np-dms.work |  | ||||||
|       - NODE_ENV=production |  | ||||||
| # docker compose -f docker-frontend-build.yml build --no-cache | # docker compose -f docker-frontend-build.yml build --no-cache | ||||||
| #  **** สำหรับ build บน server เอา ## ออก ***** | # docker compose -f docker-frontend-build.yml build --no-cache 2>&1 | tee frontend_build.log | ||||||
|  |  | ||||||
|  | # สร้าง package-lock.json | ||||||
|  | # cd frontend | ||||||
|  | # docker run --rm -v "$PWD:/app" -w /app node:24-alpine npm install | ||||||
|  |  | ||||||
| # สำหรับ build บน local | # สำหรับ build บน local | ||||||
| # cd frontend | # cd frontend | ||||||
| # docker build -t dms-frontend:dev --target dev . | # docker build -t dms-frontend:dev --target dev . | ||||||
| @@ -1,7 +1,9 @@ | |||||||
| node_modules | node_modules | ||||||
| npm-debug.log | npm-debug.log | ||||||
|  | .next | ||||||
| .next/cache | .next/cache | ||||||
| .git | .git | ||||||
| .gitignore | .gitignore | ||||||
| .DS_Store | .DS_Store | ||||||
| logs | .env*.local | ||||||
|  | *.logs | ||||||
							
								
								
									
										9
									
								
								frontend/.editorconfig
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										9
									
								
								frontend/.editorconfig
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | root = true | ||||||
|  |  | ||||||
|  | [*] | ||||||
|  | charset = utf-8 | ||||||
|  | end_of_line = lf | ||||||
|  | indent_style = space | ||||||
|  | indent_size = 2 | ||||||
|  | insert_final_newline = true | ||||||
|  | trim_trailing_whitespace = true | ||||||
							
								
								
									
										15
									
								
								frontend/.eslintrc.json
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										15
									
								
								frontend/.eslintrc.json
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | // .eslintrc.json | ||||||
|  | { | ||||||
|  |   "root": true, | ||||||
|  |   "extends": ["next/core-web-vitals"], | ||||||
|  |   "parserOptions": { | ||||||
|  |     "ecmaVersion": 2023, | ||||||
|  |     "sourceType": "module" | ||||||
|  |   }, | ||||||
|  |   "rules": { | ||||||
|  |     "react/no-unescaped-entities": "off", | ||||||
|  |     "@next/next/no-img-element": "off", | ||||||
|  |     "no-console": ["warn", { "allow": ["warn", "error"] }] | ||||||
|  |   }, | ||||||
|  |   "ignorePatterns": ["node_modules/", ".next/", "dist/", "coverage/"] | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								frontend/.prettierrc.json
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										11
									
								
								frontend/.prettierrc.json
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | // File: .prettierrc.json | ||||||
|  | { | ||||||
|  |   "semi": true, | ||||||
|  |   "singleQuote": false, | ||||||
|  |   "tabWidth": 2, | ||||||
|  |   "printWidth": 100, | ||||||
|  |   "trailingComma": "es5", | ||||||
|  |   "bracketSpacing": true, | ||||||
|  |   "arrowParens": "always", | ||||||
|  |   "endOfLine": "lf" | ||||||
|  | } | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user