36 Commits

Author SHA1 Message Date
fb26bb7b25 chore(git): ignore n8n-postgres/ and stop tracking it 2025-10-11 11:58:53 +07:00
c55f464f3c feat: Gen package-lock... 2025-10-11 10:30:47 +07:00
aa799edf2c chore(git): stop tracking top-level npm/ (NPM data) and ignore it 2025-10-11 10:13:53 +07:00
cc47c6f5f1 chore(git): stop tracking top-level npm/ (NPM data) and ignore it 2025-10-11 10:09:54 +07:00
admin
15145260f9 feat: backend rebuild 2025-10-11 09:24:40 +07:00
360ab1ac12 251011 start 2025-10-11 08:10:49 +07:00
admin
e58e164e54 update README.md 2025-10-10 16:42:32 +07:00
bbfbc5b910 fix: tailwind v4 postcss, auth-server session, eslint cleanups 2025-10-09 15:47:56 +07:00
670228b76e xxx 2025-10-05 11:57:43 +07:00
admin
754e494e7f fronted แก้ layout build 2 2025-10-05 11:05:03 +07:00
admin
5dec188744 fronted แก้ layout build dev&proc 2025-10-05 10:57:54 +07:00
admin
02e509986b fronted build dev&proc 2025-10-05 10:18:59 +07:00
admin
da568bb85f fronted build js-cookiep 2025-10-05 09:40:55 +07:00
admin
3448594bc5 Apply .gitignore cleanup 2025-10-05 09:21:04 +07:00
admin
d2a7a3e478 feat(dashboard): backend Sequelize.js 2025-10-04 17:26:36 +07:00
admin
72c2573648 feat(dashboard): backend rbac_admin.js 2025-10-04 17:11:30 +07:00
admin
c98baa94fc feat(dashboard): backend user.js 2025-10-04 17:08:58 +07:00
admin
c414899a4f feat(dashboard): backend and frontend 2025-10-04 16:46:39 +07:00
admin
1ef1f8148f feat(dashboard): backend/src/index.js 2025-10-04 16:17:21 +07:00
admin
772239e708 feat(dashboard): เพมสวนจดการ user 2025-10-04 16:07:22 +07:00
admin
7f41c35cb8 stable: auth+dashboard OK 2025-10-04 14:56:44 +07:00
admin
d3844aec71 251004 backend restore /routes/auth.js 2025-10-04 11:24:01 +07:00
admin
33022c1840 251004 frontend backend 2 2025-10-04 10:58:25 +07:00
admin
a70ad11035 251004 frontend backend 2025-10-04 10:56:56 +07:00
admin
10150583cc 251003 frontend 2 2025-10-03 16:52:13 +07:00
admin
4d7e69247d 251003 frontend NPM 2025-10-03 16:14:09 +07:00
eeb7808e29 251003 gitignore 2025-10-03 08:21:28 +07:00
admin
03a8a3b864 2nd File: frontend/app/(auth)/login/page.jsx 2025-10-02 09:49:50 +07:00
admin
6fea909902 File: frontend/app/(auth)/login/page.jsx 2025-10-02 09:04:00 +07:00
admin
dd48a26196 251002 frontend/app/(auth)/login/page.jsx 2025-10-02 08:54:52 +07:00
admin
cb4146fa35 251002 frontend/app/(protected)/layout.jsx 2025-10-02 08:30:45 +07:00
admin
60880fb12e gitignore 2025-10-02 08:11:20 +07:00
d3339d75bf layout errer 2025-10-01 17:15:51 +07:00
admin
a1e9600ad5 ปรับ frontend/app/(protected)/layout.jsx 2025-10-01 15:44:57 +07:00
admin
a3d2e24861 Merge branch 'main' of ssh://git.np-dms.work:2222/np-dms/lcbp3.np-dms.work 2025-10-01 15:33:11 +07:00
admin
2215633fb9 ปรับ frontend 2025-10-01 13:53:46 +07:00
2421 changed files with 34319 additions and 1476347 deletions

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

@@ -1,66 +1,66 @@
# Copilot instructions for DMS repository # Copilot instructions for DMS repository
This file contains short, actionable guidance for AI coding agents working in this repository. Keep edits small and focused; prefer non-invasive changes and always run the project's health checks after edits. This file contains short, actionable guidance for AI coding agents working in this repository. Keep edits small and focused; prefer non-invasive changes and always run the project's health checks after edits.
Summary (one line): Summary (one line):
- Monorepo-style Dockerized DMS app: Node (ESM) backend (Express + Sequelize + MariaDB), Next.js frontend, n8n workflows, nginx/NPM reverse proxy, and various DB admin containers. - Monorepo-style Dockerized DMS app: Node (ESM) backend (Express + Sequelize + MariaDB), Next.js frontend, n8n workflows, nginx/NPM reverse proxy, and various DB admin containers.
What to read first (order matters): What to read first (order matters):
1. `README.md` (root) — high-level architecture and host paths used on QNAP (/share/Container/dms and /share/dms-data). 1. `README.md` (root) — high-level architecture and host paths used on QNAP (/share/Container/dms and /share/dms-data).
2. `docker-compose.yml` — service boundaries, env var conventions, mounted volumes, and healthchecks. 2. `docker-compose.yml` — service boundaries, env var conventions, mounted volumes, and healthchecks.
3. `backend/README.md` and `backend/package.json` — backend runtime (Node >=20, ESM), start/dev scripts, and important env names (DB_*, JWT_*). 3. `backend/README.md` and `backend/package.json` — backend runtime (Node >=20, ESM), start/dev scripts, and important env names (DB_*, JWT_*).
4. `frontend/package.json`, `frontend/next.config.js`, `frontend/middleware.ts` — Next.js routes and auth cookie usage. 4. `frontend/package.json`, `frontend/next.config.js`, `frontend/middleware.ts` — Next.js routes and auth cookie usage.
Quick architecture notes (why things are structured this way): Quick architecture notes (why things are structured this way):
- Containers are intended to run on QNAP Container Station; many volumes map host paths under `/share/Container/dms` and `/share/dms-data` for persistent storage and uploads. - Containers are intended to run on QNAP Container Station; many volumes map host paths under `/share/Container/dms` and `/share/dms-data` for persistent storage and uploads.
- Backend is ESM Node app with Sequelize connecting to MariaDB. No project-level `.env` — environment is provided by `docker-compose.yml` or Container Station. - Backend is ESM Node app with Sequelize connecting to MariaDB. No project-level `.env` — environment is provided by `docker-compose.yml` or Container Station.
- Frontend is Next.js (server+client) running on port 3000. Middleware enforces cookie-based auth (`access_token`). - Frontend is Next.js (server+client) running on port 3000. Middleware enforces cookie-based auth (`access_token`).
- Reverse proxy (NPM) and nginx landing are used to expose services; ensure `TRUSTED_PROXIES`, `ROOT_URL`, and proxy headers are configured when editing networking code. - Reverse proxy (NPM) and nginx landing are used to expose services; ensure `TRUSTED_PROXIES`, `ROOT_URL`, and proxy headers are configured when editing networking code.
Important developer workflows (commands & checks): Important developer workflows (commands & checks):
- Backend dev server: - Backend dev server:
- npm run dev (in `backend/`) — nodemon watches `src` and restarts. Port from `PORT` env (default 3001). - npm run dev (in `backend/`) — nodemon watches `src` and restarts. Port from `PORT` env (default 3001).
- npm run health (in `backend/`) — quick healthcheck: fetches /health. - npm run health (in `backend/`) — quick healthcheck: fetches /health.
- Frontend dev server: - Frontend dev server:
- npm run dev (in `frontend/`) — next dev on port 3000. - npm run dev (in `frontend/`) — next dev on port 3000.
- Docker: use `docker-compose up -d` on the host (QNAP) to recreate services. On local dev, mount source to container as `docker-compose.yml` shows. - Docker: use `docker-compose up -d` on the host (QNAP) to recreate services. On local dev, mount source to container as `docker-compose.yml` shows.
Project-specific conventions and patterns: Project-specific conventions and patterns:
- No `.env` files in repo; service environment is provided in compose and expected on host. Do not introduce secrets into repository; use compose or host secrets. - No `.env` files in repo; service environment is provided in compose and expected on host. Do not introduce secrets into repository; use compose or host secrets.
- Ports: backend 3001, frontend 3000. Health endpoints: `/health` for both services. - Ports: backend 3001, frontend 3000. Health endpoints: `/health` for both services.
- File uploads are module-scoped: upload endpoint is `POST /api/v1/uploads/:module/:refId` and allowed `module` values are in README (rfa, correspondence, drawing, document, transmittal). - File uploads are module-scoped: upload endpoint is `POST /api/v1/uploads/:module/:refId` and allowed `module` values are in README (rfa, correspondence, drawing, document, transmittal).
- RBAC: permission strings like `rfa:create` and middleware `requirePerm('...')` (see `backend/middleware/permGuard.js`). Prefer existing middleware and permission helpers rather than inlining checks. - RBAC: permission strings like `rfa:create` and middleware `requirePerm('...')` (see `backend/middleware/permGuard.js`). Prefer existing middleware and permission helpers rather than inlining checks.
- Views endpoints require `?project_id=` for scoped queries and enforce `projectScopedView('<module>')` policy. - Views endpoints require `?project_id=` for scoped queries and enforce `projectScopedView('<module>')` policy.
Key files and directories to reference for edits or feature additions: Key files and directories to reference for edits or feature additions:
- `backend/src/` — controllers, routes, middleware, models (Sequelize). Look for `index.js`, `routes/`, `models/`, `middleware/`. - `backend/src/` — controllers, routes, middleware, models (Sequelize). Look for `index.js`, `routes/`, `models/`, `middleware/`.
- `frontend/app` and `frontend/page.jsx` — Next.js app routes and top-level page. - `frontend/app` and `frontend/page.jsx` — Next.js app routes and top-level page.
- `docker-compose.yml` — service shapes, volumes, env var names, and healthchecks (use this to know what variables to set). - `docker-compose.yml` — service shapes, volumes, env var names, and healthchecks (use this to know what variables to set).
- `README.md` (root) and `backend/README.md` — canonical list of endpoints and env vars. - `README.md` (root) and `backend/README.md` — canonical list of endpoints and env vars.
Testing and validation checklist for code changes: Testing and validation checklist for code changes:
- Backend: run `npm run lint` (placeholder) and `npm run health` in `backend/`. Start nodemon and ensure `/health` returns OK and DB connection works. - Backend: run `npm run lint` (placeholder) and `npm run health` in `backend/`. Start nodemon and ensure `/health` returns OK and DB connection works.
- Frontend: run `npm run dev` and confirm middleware redirects unauthenticated users to `/login` when visiting protected routes (see `middleware.ts` matcher). - Frontend: run `npm run dev` and confirm middleware redirects unauthenticated users to `/login` when visiting protected routes (see `middleware.ts` matcher).
- Docker compose: after edits to services or env vars, run `docker-compose up -d --build` and watch healthchecks. Check mapped host paths under `/share/Container/dms`. - Docker compose: after edits to services or env vars, run `docker-compose up -d --build` and watch healthchecks. Check mapped host paths under `/share/Container/dms`.
Common pitfalls to avoid (from repo patterns): Common pitfalls to avoid (from repo patterns):
- Do not hardcode secrets (JWT secrets, DB passwords) into code or repo files — they appear in compose for local deployment but should not be committed for production. - Do not hardcode secrets (JWT secrets, DB passwords) into code or repo files — they appear in compose for local deployment but should not be committed for production.
- File permissions: many volumes expect certain UID/GID (e.g., `USER_UID=1000`). Ensure the container user has write permission for uploads and logs. - File permissions: many volumes expect certain UID/GID (e.g., `USER_UID=1000`). Ensure the container user has write permission for uploads and logs.
- Large file uploads: proxy (NPM/nginx) may block big uploads; remember to check proxy `client_max_body_size` or NPM upload limits when debugging upload issues. - Large file uploads: proxy (NPM/nginx) may block big uploads; remember to check proxy `client_max_body_size` or NPM upload limits when debugging upload issues.
If you change routing, auth, or upload behavior: If you change routing, auth, or upload behavior:
- Update `frontend/middleware.ts` if protected path patterns change. - Update `frontend/middleware.ts` if protected path patterns change.
- Update backend `routes/` and ensure RBAC middleware usage follows `requirePerm` and `projectScopedView` patterns. - Update backend `routes/` and ensure RBAC middleware usage follows `requirePerm` and `projectScopedView` patterns.
- Run both services and test a full upload flow: login -> upload file -> download -> list files. - Run both services and test a full upload flow: login -> upload file -> download -> list files.
When you need more context, open these files first: When you need more context, open these files first:
- `docker-compose.yml` (service boundaries & env names) - `docker-compose.yml` (service boundaries & env names)
- `backend/README.md` (endpoint list & env examples) - `backend/README.md` (endpoint list & env examples)
- `backend/src/index.js` (app bootstrap & middleware wiring) - `backend/src/index.js` (app bootstrap & middleware wiring)
- `backend/src/middleware/permGuard.js` (RBAC enforcement) - `backend/src/middleware/permGuard.js` (RBAC enforcement)
- `frontend/middleware.ts` (auth enforcement for routes) - `frontend/middleware.ts` (auth enforcement for routes)
If the repo already contains a `.github/copilot-instructions.md`, merge rather than replace; preserve any specific workflow steps. If the repo already contains a `.github/copilot-instructions.md`, merge rather than replace; preserve any specific workflow steps.
Feedback request Feedback request
- Is there any additional developer workflow or file path you'd like included (build scripts, CI, or QNAP-specific steps)? If yes, point me to the file(s) and I'll integrate them. - Is there any additional developer workflow or file path you'd like included (build scripts, CI, or QNAP-specific steps)? If yes, point me to the file(s) and I'll integrate them.

191
.gitignore vendored Executable file → Normal file
View File

@@ -1,90 +1,101 @@
# ยกเว้นโฟลเดอร์ # ยกเว้นโฟลเดอร์
.devcontainer/ .devcontainer/
@Recently-Snapshot/ .qsync/
Documents/ @Recently-Snapshot/
mariadb/data/ Documents/
n8n-postgres/ mariadb/data/
phpmyadmin/sessions/ n8n*/
# ===================================================== n8n-postgres/
# IDE/Editor settings npm/
# ===================================================== phpmyadmin/
.vscode/ pgadmin/
.idea/ .tmp.driveupload
# ===================================================== .qsync
# Node.js dependencies (เฉพาะ backend และ frontend) # =====================================================
# ===================================================== # IDE/Editor settings
/backend/node_modules/ # =====================================================
/frontend/node_modules/ .vscode/
**/node_modules/ .idea/
# lockfiles # =====================================================
# /backend/package-lock.json # Node.js dependencies (เฉพาะ backend และ frontend)
# /frontend/package-lock.json # =====================================================
# **/package-lock.json /backend/node_modules/
# ===================================================== /frontend/node_modules/
# Next.js build output **/node_modules/
# ===================================================== # lockfiles
/frontend/.next/ # /backend/package-lock.json
/frontend/out/ # /frontend/package-lock.json
/frontend/.vercel/ # **/package-lock.json
# =====================================================
# Build outputs # Next.js build output
/dist # =====================================================
/build /frontend/.next/
/.next/ /frontend/out/
/out/ /frontend/.vercel/
/bin/
# Build outputs
# ===================================================== /dist
# Environment files /build
# ===================================================== /.next/
.env /out/
.env.local /bin/
.env.development.local
.env.test.local # =====================================================
.env.production.local # Environment files
# =====================================================
# ===================================================== .env
# Logs .env.local
# ===================================================== .env.development.local
/backend/logs/ .env.test.local
/frontend/logs/ .env.production.local
/logs/
*.log # =====================================================
npm-debug.log* # Logs
yarn-debug.log* # =====================================================
yarn-error.log* /backend/logs/
pnpm-debug.log* /frontend/logs/
*.tmp /logs/
*.temp *.log
ehthumbs.db npm-debug.log*
desktop.ini yarn-debug.log*
# =================================================================== yarn-error.log*
# Databases (MariaDB, Postgres) & Admin Tools (phpMyAdmin, pgAdmin) pnpm-debug.log*
# =================================================================== *.tmp
# Do not commit database data dumps which may contain sensitive info *.temp
*.dump ehthumbs.db
*.bak desktop.ini
# ===================================================================
# ===================================================== # Databases (MariaDB, Postgres) & Admin Tools (phpMyAdmin, pgAdmin)
# OS-specific junk # ===================================================================
# ===================================================== # Do not commit database data dumps which may contain sensitive info
.DS_Store *.dump
Thumbs.db *.bak
.AppleDouble
# =====================================================
# ===================================================== # OS-specific junk
# Docker-related # =====================================================
# ===================================================== .DS_Store
*.pid Thumbs.db
*.seed .AppleDouble
*.pid.lock
docker-compose.override.yml # =====================================================
docker-compose.override.*.yml # Docker-related
# =====================================================
# ===================================================== *.pid
# Cache / temp *.seed
# ===================================================== *.pid.lock
/backend/.cache/ docker-compose.override.yml
/frontend/.cache/ docker-compose.override.*.yml
.tmp/
.cache/ # =====================================================
# Cache / temp
# =====================================================
/backend/.cache/
/frontend/.cache/
.tmp/
.tmp*.*/
.cache/
# Ignore Nginx Proxy Manager data
/npm/
/n8n-postgres/

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

506
Architech.md Executable file
View File

@@ -0,0 +1,506 @@
# DMS Architecture Deep Dive (Backend + Frontend)
**Project:** Document Management System (DMS) — LCBP3
**Platform:** QNAP TS473A (Container Station)
**Last updated:** 20251007 (UTC+7)
---
## 0) TL;DR (Executive Summary)
* Reverse proxy (Nginx/NPM) เผยแพร่ Frontend (Next.js) และ Backend (Node.js/Express) ผ่าน HTTPS (HSTS)
* Backend เชื่อม MariaDB 10.11 (ข้อมูลหลัก DMS) และแยก n8n + Postgres 16 สำหรับ workflow
* RBAC/ABAC ถูกบังคับใช้งานใน middleware + มีชุด SQL (tables → triggers → procedures → views → seed)
* ไฟล์จริง (PDF/DWG) เก็บนอก webroot ที่ **/share/dmsdata** พร้อมมาตรฐานการตั้งชื่อ+โฟลเดอร์
* Dev/Prod แยกชัดเจนผ่าน Docker multistage + dockercompose + โฟลเดอร์ persist logs/config/certs
---
## 1) Runtime Topology & Trust Boundaries
```
Internet Clients (Browser)
│ HTTPS 443 (HSTS) [QNAP mgmt = 8443]
┌─────────────────────────────────────────────────────┐
│ Reverse Proxy Layer │
│ ├─ Nginx (Alpine) or Nginx Proxy Manager (NPM) │
│ ├─ TLS (LE cert; SAN multisubdomain) │
│ └─ Routes: │
│ • /, /_next/* → Frontend (Next.js :3000) │
│ • /api/* → Backend (Express :3001) │
│ • /pma/* → phpMyAdmin │
│ • /n8n/* → n8n (Workflows) │
└─────────────────────────────────────────────────────┘
│ │
│ └──────────┐
▼ │
Frontend (Next.js) │
│ Cookie-based Auth (HttpOnly) │
▼ ▼
Backend (Node/Express ESM) ─────────► MariaDB 10.11
│ │
└────────────────────────────────────┘
Project data (.pdf/.dwg) @ /share/dms-data
n8n (workflows) ──► Postgres 16 (separate DB for automations)
```
**Trust Boundaries**
* Public zone: Internet ↔ Reverse proxy
* App zone: Reverse proxy ↔ FE/BE containers (internal Docker network)
* Data zone: Backend ↔ Databases (MariaDB, Postgres) + `/share/dms-data`
---
## 2) Frontend Architecture (Next.js / React)
### 2.1 Stack & Key libs
* **Next.js (App Router)**, **React**, ESM
* **Tailwind CSS**, **PostCSS**, **shadcn/ui** (components.json)
* Fetch API (credentials include) → Cookie Auth (HttpOnly)
### 2.2 Directory Layout
```
/frontend/
├─ app/
│ ├─ login/
│ ├─ dashboard/
│ ├─ users/
│ ├─ correspondences/
│ ├─ health/
│ └─ layout.tsx / page.tsx (ตาม App Router)
├─ public/
├─ Dockerfile (multi-stage: dev/prod)
├─ package.json
├─ next.config.js
└─ ...
```
### 2.3 Routing & Layouts
* **Public**: `/login`, `/health`
* **Protected**: `/dashboard`, `/users`, `/correspondences`, ... (client-side guard)
* เก็บ **middleware.ts (ของเดิม)** เพื่อหลีกเลี่ยง regression; ใช้ clientguard + server action อย่างระมัดระวัง
### 2.4 Auth Flow (Cookie-based)
1. ผู้ใช้ submit form `/login``POST /api/auth/login` (Backend)
2. Backend set **HttpOnly** cookie (JWT) + `SameSite=Lax/Strict`, `Secure`
3. หน้า protected เรียก `GET /api/auth/me` เพื่อตรวจสอบสถานะ
4. หาก 401 → redirect → `/login`
> **CORS/Fetch**: เปิด `credentials: 'include'` ทุกครั้ง, ตั้ง `NEXT_PUBLIC_API_BASE` เป็น origin ของ backend ผ่าน proxy (เช่น `https://lcbp3.np-dms.work`)
### 2.5 UI/UX
* Seablue palette, sidebar พับได้, cardbased KPI
* ตารางข้อมูลเตรียมรองรับ **serverside DataTables**
* shadcn/ui: Button, Card, Badge, Tabs, Dropdown, Tooltip, Switch, etc.
### 2.6 Config & ENV
* `NEXT_PUBLIC_API_BASE` (ex: `https://lcbp3.np-dms.work`)
* Build output แยก dev/prod; ระวัง EACCES บน QNAP → ใช้ user `node` + ปรับสิทธิ์โวลุ่ม `.next/*`
### 2.7 Error Handling & Observability (FE)
* Global error boundary (app router) + toast/alert patterns
* Network layer: แยก handler สำหรับ 401/403/500 + retry/backoff ที่จำเป็น
* Metrics (optional): webvitals, UX timing (เก็บฝั่ง n8n หรือ simple logging)
---
## 3) Backend Architecture (Node.js ESM / Express)
### 3.1 Stack & Structure
* Node 20.x, **ESM** modules, **Express**
* `mysql2/promise`, `jsonwebtoken`, `cookie-parser`, `cors`, `helmet`, `winston/morgan`
```tree
/backend/
├─ src/
│ ├─ index.js # bootstrap server, CORS, cookies, health
│ ├─ routes/
│ │ ├─ auth.js # /api/auth/* (login, me, logout)
│ │ ├─ users.js # /api/users/*
│ │ ├─ correspondences.js # /api/correspondences/*
│ │ ├─ drawings.js # /api/drawings/*
│ │ ├─ rfas.js # /api/rfas/*
│ │ └─ transmittals.js # /api/transmittals/*
│ ├─ middleware/
│ │ ├─ authGuard.js # verify JWT from cookie
│ │ ├─ requirePermission.js# RBAC/ABAC enforcement
│ │ ├─ errorHandler.js
│ │ └─ requestLogger.js
│ ├─ db/
│ │ ├─ pool.js # createPool, sane defaults
│ │ └─ models/ # query builders (User, Drawing, ...)
│ ├─ utils/
│ │ ├─ hash.js (bcrypt/argon2)
│ │ ├─ jwt.js
│ │ ├─ pagination.js
│ │ └─ responses.js
│ └─ config/
│ └─ index.js # env, constants
├─ Dockerfile
└─ package.json
```
### 3.2 Request Lifecycle
1. `helmet` + `cors` (allow specific origin; credentials true)
2. `cookie-parser`, `json limit` (e.g., 2MB)
3. `requestLogger` → trace + response time
4. Route handler → `authGuard` (protected) → `requirePermission` (perroute) → Controller
5. Error bubbles → `errorHandler` (JSON shape, status map)
### 3.3 Auth & RBAC/ABAC
* **JWT** ใน HttpOnly cookie; Claims: `sub` (user_id), `roles`, `exp`
* **authGuard**: ตรวจ token → แนบ `req.user`
* **requirePermission**: เช็ค permission ตามเส้นทาง/วิธี; แผนขยาย ABAC (เช่น project scope, owner, doc state)
* Roles/Permissions ถูก seed ใน SQL; มี **view เมทริกซ์** เพื่อ debug (เช่น `v_role_permission_matrix`)
**ตัวอย่าง pseudo** `requirePermission(permission)`
```js
export const requirePermission = (perm) => async (req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'Unauthenticated' });
const ok = await checkPermission(req.user.user_id, perm, req.context);
if (!ok) return res.status(403).json({ error: 'Forbidden' });
return next();
};
```
### 3.4 Database Access & Pooling
* `createPool({ connectionLimit: 10~25, queueLimit: 0, waitForConnections: true })`
* ใช้ parameterized queries เสมอ; ปรับ `sql_mode` ที่จำเป็นใน `my.cnf`
### 3.5 File Storage & Secure Download
* Root: **/share/dmsdata**
* โครงโฟลเดอร์: `{module}/{yyyy}/{mm}/{entityId}/` + ชื่อไฟล์ตามมาตรฐาน (เช่น `DRW-<code>-REV-<rev>.pdf`)
* Endpoint download: ตรวจสิทธิ์ (RBAC/ABAC) → `res.sendFile()`/stream; ป้องกัน path traversal
* MIME allowlist + size limit + virus scan (optional; ภายหลัง)
### 3.6 Health & Readiness
* `GET /api/health``{ ok: true }`
* (optional) `/api/ready` ตรวจ DB ping + disk space (dmsdata)
### 3.7 Config & ENV (BE)
* `DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME`
* `JWT_SECRET, COOKIE_NAME, COOKIE_SAMESITE, COOKIE_SECURE`
* `CORS_ORIGIN, LOG_LEVEL, APP_BASE_URL`
* `FILE_ROOT=/share/dms-data`
### 3.8 Logging
* Access log (morgan) + App log (winston) → `/share/Container/dms/logs/backend/`
* รูปแบบ JSON (timestamp, level, msg, reqId) + daily rotation (logrotate/containerside)
---
## 4) Database (MariaDB 10.11)
### 4.1 Schema Overview (ย่อ)
* **RBAC core**: `users`, `roles`, `permissions`, `user_roles`, `role_permissions`
* **Domain**: `drawings`, `contracts`, `correspondences`, `rfas`, `transmittals`, `organizations`, `projects`, ...
* **Audit**: `audit_logs` (แผนขยาย), `deleted_at` (soft delete, แผนงาน)
```
[users]──<user_roles>──[roles]──<role_permissions>──[permissions]
└── activities/audit_logs (future expansion)
[drawings]──<mapping>──[contracts]
[rfas]──<links>──[drawings]
[correspondences] (internal/external flag)
```
### 4.2 Init SQL Pipeline
1. `01_*_deploy_table_rbac.sql` — สร้างตารางหลักทั้งหมด + RBAC
2. `02_*_triggers.sql` — บังคับ data rules, autoaudit fields
3. `03_*_procedures_handlers.sql` — upsert/bulk handlers (เช่น `sp_bulk_import_contract_dwg`)
4. `04_*_views.sql` — รายงาน/เมทริกซ์สิทธิ์ (`v_role_permission_matrix`, etc.)
5. `05_*_seed_data.sql` — ค่าพื้นฐาน domain (project, categories, statuses)
6. `06_*_seed_users.sql` — บัญชีเริ่มต้น (superadmin, editors, viewers)
7. `07_*_seed_contract_dwg.sql` — ข้อมูลตัวอย่างแบบสัญญา
### 4.3 Indexing & Performance
* Composite indexes ตามคอลัมน์ filter/sort (เช่น `(project_id, updated_at DESC)`)
* Fulltext index (optional) สำหรับ advanced search
* Query plan review (EXPLAIN) + เพิ่ม covering index ตามรายงาน
### 4.4 MySQL/MariaDB Config (my.cnf — แนวทาง)
```
[mysqld]
innodb_buffer_pool_size = 4G # ปรับตาม RAM/QNAP
innodb_log_file_size = 512M
innodb_flush_log_at_trx_commit = 1
max_connections = 200
sql_mode = STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
```
> ปรับค่าให้เหมาะกับ workload จริง + เฝ้าดู IO/CPU ของ QNAP
### 4.5 Backup/Restore
* Logical backup: `mysqldump --routines --triggers --single-transaction`
* Physical (snapshot QNAP) + schedule ผ่าน n8n/cron
* เก็บสำเนา offNAS (encrypted)
---
## 5) Reverse Proxy & TLS
### 5.1 Nginx (Alpine) — ตัวอย่าง server block
> **สำคัญ:** บนสภาพแวดล้อมนี้ ให้ใช้คนละบรรทัด:
> `listen 443 ssl;`
> `http2 on;`
> หลีกเลี่ยง `listen 443 ssl http2;`
```nginx
server {
listen 80;
server_name lcbp3.np-dms.work;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name lcbp3.np-dms.work;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; preload" always;
# Frontend
location / {
proxy_pass http://frontend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Next.js static
location /_next/ {
proxy_pass http://frontend:3000;
}
# Backend API
location /api/ {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://backend:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
# phpMyAdmin (sub-path)
location /pma/ {
proxy_pass http://phpmyadmin:80/;
}
# n8n
location /n8n/ {
proxy_pass http://n8n:5678/;
}
}
```
### 5.2 Nginx Proxy Manager (NPM) — Tips
* ระวังอย่าใส่ `proxy_http_version` ซ้ำซ้อน (duplicate directive) ใน Advanced
* ถ้าต้องแก้ไฟล์ด้านใน NPM → ระวังไฟล์ใน `/data/nginx/proxy_host/*.conf`
* จัดการ certificate / SAN หลาย subdomain ใน UI แต่ mainten ดีเรื่อง symlink/renew
### 5.3 TLS & Certificates
* Lets Encrypt (HTTP01 webroot/standalone) + HSTS
* QNAP mgmt เปลี่ยนเป็น 8443 → พอร์ต 443 public ว่างสำหรับ Nginx/NPM
---
## 6) Docker Compose Topology
### 6.1 Services (สรุป)
* `frontend` (Next.js) :3000
* `backend` (Express) :3001
* `mariadb` (10.11) :3306 (internal)
* `phpmyadmin` :80 (internal)
* `nginx` or `npm` :80/443 (published)
* `n8n` :5678 (internal)
* `postgres_n8n` (16-alpine)
* `pgadmin4`
### 6.2 Volumes & Paths
```
/share/Container/dms/
├─ mariadb/data
├─ mariadb/init/*.sql
├─ backend/ (code)
├─ frontend/ (code)
├─ phpmyadmin/{sessions,tmp,config.user.inc.php}
├─ nginx/{nginx.conf,dms.conf,certs/}
├─ n8n, n8n-postgres, n8n-cache
└─ logs/{backend,frontend,nginx,pgadmin,phpmyadmin,postgres_n8n}
/share/dms-data (pdf/dwg storage)
```
### 6.3 Healthchecks (suggested)
* **backend**: curl `http://localhost:3001/api/health`
* **frontend**: curl `/health` (simple JSON)
* **mariadb**: `mysqladmin ping` with credentials
* **nginx**: `nginx -t` at startup
### 6.4 Security Hardening
* รัน container ด้วย user nonroot (`user: node` สำหรับ FE/BE)
* จำกัด capabilities; readonly FS (ยกเว้นโวลุ่มจำเป็น)
* เฉพาะ backend เมานต์ `/share/dms-data`
---
## 7) Observability, Ops, and Troubleshooting
### 7.1 Logs
* Frontend → `/logs/frontend/*`
* Backend → `/logs/backend/*` (app/access/error)
* Nginx/NPM → `/logs/nginx/*`
* MariaDB → default datadir log + slow query (เปิดใน my.cnf หากต้องการ)
### 7.2 Common Issues & Playbooks
* **401 Unauthenticated**: ตรวจ `authGuard` → JWT cookie มี/หมดอายุ → เวลา server/FE sync → CORS `credentials: true`
* **EACCES Next.js**: สิทธิ์ `.next/*` + run as `node`, โวลุ่ม map ถูก user:group
* **NPM duplicate directive**: ลบซ้ำ `proxy_http_version` ใน Advanced / ตรวจ `proxy_host/*.conf`
* **LE cert path/symlink**: ตรวจ `/etc/letsencrypt/live/npm-*` symlink ชี้ถูก
* **DB field not found**: ตรวจ schema vs code (migration/init SQL) → sync ให้ตรง
### 7.3 Performance Guides
* **Backend**: keepalive, gzip/deflate at proxy, pool 1025, paginate, avoid N+1
* **Frontend**: prefetch critical routes, cache static, image optimization
* **DB**: เพิ่ม index จุด filter, analyze query (EXPLAIN), ปรับ buffer pool
---
## 8) Security & Compliance
* **HTTPS only** + HSTS (preload)
* **CORS**: allow list เฉพาะ FE origin; `Access-Control-Allow-Credentials: true`
* **Cookie**: HttpOnly, Secure, SameSite=Lax/Strict
* **Input Validation**: celebrate/zod (optional) + sanitize
* **Rate limiting**: per IP/route (optional)
* **AuditLog**: วางแผนเพิ่ม ครอบคลุม CRUD + mapping (actor, action, entity, before/after)
* **Backups**: DB + `/share/dms-data` + config (encrypted offNAS)
---
## 9) Backlog → Architecture Mapping
1. **RBAC Enforcement ครบ** → เติม `requirePermission` ทุก route + test matrix ผ่าน view
2. **AuditLog ครบ CRUD/Mapping** → trigger + table `audit_logs` + BE hook
3. **Upload/Download จริงของ Drawing Revisions** → BE endpoints + virus scan (optional)
4. **Dashboard KPI** → BE summary endpoints + FE cards/charts
5. **Serverside DataTables** → paging/sort/filter + indexesรองรับ
6. **รายงาน Export CSV/Excel/PDF** → BE export endpoints + FE buttons
7. **Soft delete** (`deleted_at`) → BE filter default scope + restore endpoint
8. **Validation เข้ม** → celebrate/zod schema + consistent error shape
9. **Indexing/Perf** → slow query log + EXPLAIN review
10. **Job/Cron Deadline Alerts** → n8n schedule + SMTP
---
## 10) Port & ENV Matrix (Quick Ref)
| Component | Ports | Key ENV |
| --------- | --------------- | ------------------------------------------------ |
| Nginx/NPM | 80/443 (public) | SSL paths, HSTS |
| Frontend | 3000 (internal) | `NEXT_PUBLIC_API_BASE` |
| Backend | 3001 (internal) | `DB_*`, `JWT_SECRET`, `CORS_ORIGIN`, `FILE_ROOT` |
| MariaDB | 3306 (internal) | `MY_CNF`, credentials |
| n8n | 5678 (internal) | `N8N_*`, webhook URL under `/n8n/` |
| Postgres | 5432 (internal) | n8n DB |
**QNAP mgmt**: 8443 (already moved)
---
## 11) Sample Snippets
### 11.1 Backend CORS (credentials)
```js
app.use(cors({
origin: ['https://lcbp3.np-dms.work'],
credentials: true,
}));
```
### 11.2 Secure Download (guarded)
```js
router.get('/files/:module/:id/:filename', authGuard, requirePermission('file.read'), async (req, res) => {
const { module, id, filename } = req.params;
// 1) ABAC: verify user can access this module/entity
const ok = await canReadFile(req.user.user_id, module, id);
if (!ok) return res.status(403).json({ error: 'Forbidden' });
const abs = path.join(FILE_ROOT, module, id, filename);
if (!abs.startsWith(FILE_ROOT)) return res.status(400).json({ error: 'Bad path' });
return res.sendFile(abs);
});
```
### 11.3 Healthcheck
```js
router.get('/health', (req, res) => res.json({ ok: true }));
```
---
## 12) Deployment Workflow (Suggested)
1. Git (Gitea) branch strategy `feature/*` → PR → main
2. Build images (dev/prod) via Dockerfile multistage; pin Node/MariaDB versions
3. `docker compose up -d --build` จาก `/share/Container/dms`
4. Validate: `/health`, `/api/health`, login roundtrip
5. Monitor logs + baseline perf; run SQL smoke tests (views/triggers/procs)
---
## 13) Appendix
* **Naming conventions**: snake_case DB, camelCase JS
* **Timezones**: store UTC in DB; display in app TZ (+07:00)
* **Character set**: UTF8 (`utf8mb4_unicode_ci`)
* **Large file policy**: size limit (e.g., 50200MB), allowlist extensions
* **Retention**: archive strategy for old revisions (optional)
---
> หากต้องการ เวอร์ชัน **README.md พร้อมโค้ดตัวอย่าง compose/nginx** จัดรูปแบบให้นำไปวางใน repo ได้ทันที แจ้งได้เลยว่าจะให้แตกไฟล์เป็น `/docs/Architecture.md` + `/nginx/dms.conf` + `/docker-compose.yml` template หรือรูปแบบอื่นที่สะดวกต่อการใช้งานของทีม

View File

@@ -1,483 +0,0 @@
diff --git a/backend/src/middleware/requireBearer.js b/backend/src/middleware/requireBearer.js
new file mode 100644
index 0000000..1111111
--- /dev/null
+++ b/backend/src/middleware/requireBearer.js
@@ -0,0 +1,44 @@
+// backend/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" });
+ }
+}
diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js
index 2222222..3333333 100644
--- a/backend/src/routes/auth.js
+++ b/backend/src/routes/auth.js
@@ -1,99 +1,109 @@
-// (เดิม) ผูกกับคุกกี้ / ส่ง ok:true ฯลฯ
+// backend/src/routes/auth.js — Bearer Token ล้วน
import { Router } from "express";
import jwt from "jsonwebtoken";
-import { findUserByUsername } from "../db/models/users.js";
+import { findUserByUsername, findUserById } from "../db/models/users.js";
import { verifyPassword } from "../utils/passwords.js";
-// NOTE: ลบการใช้งาน res.cookie(...) ทั้งหมด
+// NOTE: ไม่มีการใช้ res.cookie(...) อีกต่อไป
const router = Router();
function signAccessToken(user) {
return jwt.sign(
{ user_id: user.user_id, username: user.username },
process.env.JWT_ACCESS_SECRET,
- { issuer: "dms-backend", expiresIn: "30m" } // ปรับได้
+ { issuer: "dms-backend", expiresIn: "30m" }
);
}
function signRefreshToken(user) {
return jwt.sign(
- { user_id: user.user_id, username: user.username },
+ { user_id: user.user_id, username: user.username, t: "refresh" },
process.env.JWT_REFRESH_SECRET,
{ issuer: "dms-backend", expiresIn: "30d" }
);
}
router.post("/login", async (req, res) => {
const { username, password } = req.body || {};
const user = await findUserByUsername(username);
if (!user || !(await verifyPassword(password, user.password_hash))) {
return res.status(401).json({ error: "INVALID_CREDENTIALS" });
}
const token = signAccessToken(user);
const refresh_token = signRefreshToken(user);
return res.json({
token,
refresh_token,
user: {
user_id: user.user_id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
},
});
});
+router.post("/refresh", async (req, res) => {
+ const hdr = req.get("Authorization") || "";
+ const m = hdr.match(/^Bearer\s+(.+)$/i);
+ const r = m?.[1];
+ if (!r) return res.status(401).json({ error: "NO_REFRESH_TOKEN" });
+ try {
+ const payload = jwt.verify(r, process.env.JWT_REFRESH_SECRET, {
+ issuer: "dms-backend",
+ });
+ const user = await findUserById(payload.user_id);
+ if (!user) return res.status(401).json({ error: "USER_NOT_FOUND" });
+ const token = signAccessToken(user);
+ return res.json({ token });
+ } catch {
+ return res.status(401).json({ error: "INVALID_REFRESH_TOKEN" });
+ }
+});
+
export default router;
diff --git a/backend/src/index.js b/backend/src/index.js
index 4444444..5555555 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -1,60 +1,69 @@
import express from "express";
import cors from "cors";
import authRouter from "./routes/auth.js";
+import { requireBearer } from "./middleware/requireBearer.js";
-// import routers อื่น ๆ ตามจริง เช่น rfasRouter, transmittalsRouter
const app = express();
-// CORS เดิม (อาจมี credentials)
-app.use(cors({
- origin: true,
- credentials: true,
-}));
+// ✅ CORS สำหรับ Bearer: ไม่ต้อง credentials, อนุญาต Authorization header
+app.use(cors({
+ origin: [
+ "https://lcbp3.np-dms.work",
+ "http://localhost:3000"
+ ],
+ methods: ["GET","POST","PUT","PATCH","DELETE","OPTIONS"],
+ allowedHeaders: ["Authorization","Content-Type","Accept","Origin","Referer","User-Agent","X-Requested-With","Cache-Control","Pragma"],
+ exposedHeaders: ["Content-Disposition","Content-Length"]
+}));
app.use(express.json());
-// routes เดิม
-app.use("/api/auth", authRouter);
-// app.use("/api/rfas", rfasRouter);
-// app.use("/api/transmittals", transmittalsRouter);
+// ✅ เส้นทาง auth (ไม่ต้องมี token)
+app.use("/api/auth", authRouter);
+
+// ✅ ตั้ง guard สำหรับเส้นทางที่เหลือต้องล็อกอิน
+app.use("/api", requireBearer);
+// แล้วค่อย mount routers protected ใต้ /api
+// app.use("/api/rfas", rfasRouter);
+// app.use("/api/transmittals", transmittalsRouter);
app.use((err, _req, res, _next) => {
console.error(err);
res.status(500).json({ error: "INTERNAL_SERVER_ERROR" });
});
const port = process.env.PORT || 4000;
app.listen(port, () => console.log(`backend listening on :${port}`));
diff --git a/frontend/app/(auth)/login/page.jsx b/frontend/app/(auth)/login/page.jsx
index 6666666..7777777 100644
--- a/frontend/app/(auth)/login/page.jsx
+++ b/frontend/app/(auth)/login/page.jsx
@@ -1,200 +1,236 @@
// File: frontend/app/(auth)/login/page.jsx
"use client";
-// เวอร์ชันเดิม
+// ✅ Bearer-only + Debug toggle (NEXT_PUBLIC_DEBUG_AUTH)
import { useState, useMemo, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
-const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
+const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
+const DEBUG =
+ String(process.env.NEXT_PUBLIC_DEBUG_AUTH || "").trim() !== "" &&
+ process.env.NEXT_PUBLIC_DEBUG_AUTH !== "0" &&
+ process.env.NEXT_PUBLIC_DEBUG_AUTH !== "false";
+function dlog(...args) {
+ if (DEBUG && typeof window !== "undefined") console.debug("[login]", ...args);
+}
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const nextPath = useMemo(
() => searchParams.get("next") || "/dashboard",
[searchParams]
);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPw, setShowPw] = useState(false);
const [remember, setRemember] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState("");
async function onSubmit(e) {
e.preventDefault();
setErr("");
if (!username.trim() || !password) {
setErr("กรอกชื่อผู้ใช้และรหัสผ่านให้ครบ");
return;
}
try {
setSubmitting(true);
+ dlog("API_BASE =", API_BASE || "(empty → relative)");
+ dlog("nextPath =", nextPath, "remember =", remember);
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
cache: "no-store",
});
- const data = await res.json().catch(() => ({}));
+ dlog("response.status =", res.status);
+ dlog("response.headers.content-type =", res.headers.get("content-type"));
+ let data = {};
+ try { data = await res.json(); } catch (e) { dlog("response.json() error =", e); }
+ dlog("response.body =", data);
if (!res.ok) {
- setErr(data?.error || "เข้าสู่ระบบไม่สำเร็จ");
+ const msg =
+ data?.error === "INVALID_CREDENTIALS"
+ ? "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง"
+ : data?.error || `เข้าสู่ระบบไม่สำเร็จ (HTTP ${res.status})`;
+ dlog("login FAILED →", msg);
+ setErr(msg);
return;
}
+ if (!data?.token) {
+ dlog("login FAILED → data.token not found");
+ setErr("รูปแบบข้อมูลตอบกลับไม่ถูกต้อง (ไม่มี token)");
+ return;
+ }
const storage = remember ? window.localStorage : window.sessionStorage;
storage.setItem("dms.token", data.token);
storage.setItem("dms.refresh_token", data.refresh_token);
storage.setItem("dms.user", JSON.stringify(data.user || {}));
+ dlog("token stored in", remember ? "localStorage" : "sessionStorage");
try {
window.dispatchEvent(
new StorageEvent("storage", { key: "dms.auth", newValue: "login" })
);
} catch {}
- router.replace(nextPath);
+ dlog("navigating →", nextPath);
+ router.replace(nextPath);
} catch (e) {
+ dlog("exception =", e);
setErr("เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาลองใหม่");
} finally {
setSubmitting(false);
+ dlog("done");
}
}
return (
<div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4">
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-sky-800">เข้าสู่ระบบ</CardTitle>
<CardDescription className="text-sky-700">Document Management System • LCBP3</CardDescription>
</CardHeader>
<CardContent>
{err ? (
<Alert className="mb-4"><AlertDescription>{err}</AlertDescription></Alert>
) : null}
<form onSubmit={onSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="username">ชื่อผู้ใช้</Label>
<Input id="username" autoFocus autoComplete="username" value={username}
onChange={(e) => setUsername(e.target.value)} placeholder="เช่น superadmin" disabled={submitting}/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">รหัสผ่าน</Label>
<div className="relative">
<Input id="password" type={showPw ? "text" : "password"} autoComplete="current-password"
value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••"
disabled={submitting} className="pr-10"/>
<button type="button" onClick={() => setShowPw((v) => !v)}
className="absolute inset-y-0 px-2 my-auto text-xs bg-white border rounded-md right-2 hover:bg-slate-50"
aria-label={showPw ? "ซ่อนรหัสผ่าน" : "แสดงรหัสผ่าน"} disabled={submitting}>
{showPw ? "Hide" : "Show"}
</button>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<label className="inline-flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" className="size-4 accent-sky-700"
checked={remember} onChange={(e) => setRemember(e.target.checked)} disabled={submitting}/>
จดจำฉันไว้ในเครื่องนี้
</label>
<a href="/forgot-password" className="text-sm text-sky-700 hover:text-sky-900 hover:underline">ลืมรหัสผ่าน?</a>
</div>
<Button type="submit" disabled={submitting} className="mt-2 bg-sky-700 hover:bg-sky-800">
{submitting ? (<span className="inline-flex items-center gap-2"><Spinner /> กำลังเข้าสู่ระบบ…</span>) : ("เข้าสู่ระบบ")}
</Button>
+ {DEBUG ? (
+ <p className="mt-2 text-xs text-slate-500">
+ DEBUG: NEXT_PUBLIC_API_BASE = <code>{API_BASE || "(empty)"}</code>
+ </p>
+ ) : null}
</form>
</CardContent>
<CardFooter className="text-xs text-center text-slate-500">
&copy; {new Date().getFullYear()} np-dms.work
</CardFooter>
</Card>
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<LoginPageSkeleton />}>
<LoginForm />
</Suspense>
);
}
function LoginPageSkeleton() {
return (
<div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4">
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-sky-800">เข้าสู่ระบบ</CardTitle>
<CardDescription className="text-sky-700">Document Management System • LCBP3</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 animate-pulse">
<div className="h-10 rounded bg-slate-200"></div>
<div className="h-10 rounded bg-slate-200"></div>
<div className="h-10 rounded bg-slate-200"></div>
</div>
</CardContent>
</Card>
</div>
);
}
function Spinner() {
return (
<svg className="animate-spin size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
);
}
diff --git a/frontend/app/(protected)/layout.jsx b/frontend/app/(protected)/layout.jsx
new file mode 100644
index 0000000..8888888
--- /dev/null
+++ b/frontend/app/(protected)/layout.jsx
@@ -0,0 +1,38 @@
+"use client";
+import { useEffect, useState } from "react";
+import { usePathname, useRouter } from "next/navigation";
+
+export default function ProtectedLayout({ children }) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ try {
+ const token =
+ (typeof window !== "undefined" &&
+ (localStorage.getItem("dms.token") ||
+ sessionStorage.getItem("dms.token"))) ||
+ null;
+ if (!token) {
+ const next = encodeURIComponent(pathname || "/dashboard");
+ router.replace(`/login?next=${next}`);
+ return;
+ }
+ } finally {
+ setReady(true);
+ }
+ }, [pathname, router]);
+
+ if (!ready) {
+ return (
+ <div className="grid min-h-[calc(100vh-4rem)] place-items-center p-6 text-slate-600">
+ กำลังตรวจสิทธิ์…
+ </div>
+ );
+ }
+ return <>{children}</>;
+}
diff --git a/frontend/lib/api.js b/frontend/lib/api.js
new file mode 100644
index 0000000..9999999
--- /dev/null
+++ b/frontend/lib/api.js
@@ -0,0 +1,45 @@
+// frontend/lib/api.js
+const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
+
+function getToken() {
+ if (typeof window === "undefined") return null;
+ return localStorage.getItem("dms.token") || sessionStorage.getItem("dms.token");
+}
+
+export async function apiFetch(path, options = {}) {
+ const token = getToken();
+ const headers = new Headers(options.headers || {});
+ headers.set("Accept", "application/json");
+ if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
+ if (token) headers.set("Authorization", `Bearer ${token}`);
+
+ const res = await fetch(`${API_BASE}${path}`, { ...options, headers, cache: "no-store" });
+
+ if (res.status === 401) {
+ const refresh =
+ localStorage.getItem("dms.refresh_token") || sessionStorage.getItem("dms.refresh_token");
+ if (refresh) {
+ const r = await fetch(`${API_BASE}/api/auth/refresh`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${refresh}` },
+ });
+ if (r.ok) {
+ const { token: newToken } = await r.json();
+ const store = localStorage.getItem("dms.refresh_token") ? localStorage : sessionStorage;
+ store.setItem("dms.token", newToken);
+ const headers2 = new Headers(headers);
+ headers2.set("Authorization", `Bearer ${newToken}`);
+ return fetch(`${API_BASE}${path}`, { ...options, headers: headers2, cache: "no-store" });
+ }
+ }
+ }
+ return res;
+}
diff --git a/frontend/middleware.ts b/frontend/middleware.ts
index aaaaaaa..bbbbbbb 100644
--- a/frontend/middleware.ts
+++ b/frontend/middleware.ts
@@ -1,15 +1,14 @@
-import { NextResponse } from "next/server";
-import type { NextRequest } from "next/server";
-
-// เดิม: ตรวจคุกกี้แล้ว redirect /dashboard
-export function middleware(req: NextRequest) {
- // ... logic เดิมที่ใช้คุกกี้
- return NextResponse.next();
-}
-
-export const config = {
- matcher: ["/(protected/:path*)","/dashboard","/users/:path*","/api/:path*"],
-};
+import { NextResponse } from "next/server";
+// ✅ ไม่บล็อกเพจอีกต่อไป (Bearer อยู่ใน storage ฝั่ง client)
+export function middleware() {
+ return NextResponse.next();
+}
+// จำกัดให้ทำงานเฉพาะ /api ถ้าต้องการใช้ในอนาคต (ตอนนี้ผ่านเฉย ๆ)
+export const config = { matcher: ["/api/:path*"] };
diff --git a/frontend/app/(protected)/dashboard/page.jsx b/frontend/app/(protected)/dashboard/page.jsx
new file mode 100644
index 0000000..ccccccc
--- /dev/null
+++ b/frontend/app/(protected)/dashboard/page.jsx
@@ -0,0 +1,11 @@
+"use client";
+export default function DashboardPage() {
+ return (
+ <main className="p-6">
+ <h1 className="text-2xl font-semibold text-sky-800">Dashboard</h1>
+ <p className="text-slate-600 mt-2">
+ ยินดีต้อนรับสู่ DMS
+ </p>
+ </main>
+ );
+}

882
README.md Executable file → Normal file
View File

@@ -1,105 +1,777 @@
# บทบาท: คุณคือ Programmer และ Document Engineer ที่เชี่ยวชาญ # 📝 0. Project Title: Document Management System (DMS) Web Application for Laem Chabang Port Development Project, Phase 3
1. การพัฒนาเว็บแอป (Web Application Development)
2. Configuration of Container Station on QNAP ## 0. Project
3. Database: mariadb:10.11
4. Database management: phpmyadmin:5-apache ### 📌 0.1 Project Overview / Description
5. Backend: node:.js (ESM)
6. Frontend: next.js, react - ระบบ Document Management System (DMS) เป็นเว็บแอปพลิเคชันที่ออกแบบมาเพื่อจัดการเอกสารภายในโครงการอย่างมีประสิทธิภาพ
7. Workflow automation: n8n: - โดยมีฟังก์ชันหลักในการอัปโหลด จัดเก็บ ค้นหา แชร์ และควบคุมสิทธิ์การเข้าถึงเอกสาร
8. Workflow database: postgres:16-alpine - ระบบนี้จะช่วยลดการใช้เอกสารกระดาษ เพิ่มความปลอดภัยในการจัดเก็บข้อมูล
9. Workflow database management: pgadmin4 - เพิ่มความสะดวกในการทำงานร่วมกันระหว่างองกรณ์
10. Reverse proxy: nginx:1.27-alpine
11. linux on QNAP ### 🎯 0.2 Objectives
12. การจัดการฐานข้อมูล (Database Management)
13. การวิเคราะห์ฐานข้อมูล (Database Analysis) - พัฒนาระบบที่สามารถจัดการเอกสารได้อย่างเป็นระบบ
14. การจัดการฐานข้อมูลเชิงสัมพันธ์ (Relational Databases) - ลดความซ้ำซ้อนในการจัดเก็บเอกสาร
15. ภาษา SQL - เพิ่มความปลอดภัยในการเข้าถึงและจัดการเอกสาร
16. RBAC - รองรับการทำงานร่วมกันแบบออนไลน์
# ระบบที่ใช้ ### 📦 0.3 Scope of Work
## Server
- ใช้ Container Station เป็น SERVER บน QNAP (Model: TS-473A, RAM: 32GB, CPU: AMD Ryzen V1500B 4 cores 8 threads) ระบบจะครอบคลุมฟีเจอร์หลักดังนี้:
*** เปลี่ยน port 443 ของ QNAP เป็น 8443 แล้ว ***
## การพัฒนาโครงการ - การลงทะเบียนและเข้าสู่ระบบ ของผู้ใช้งาน
- ด้วย Visual Studio Code บน Windows 11 - การอัปโหลดและจัดเก็บเอกสารในรูปแบบต่าง ๆ (PDF, DOCX, XLSX ฯลฯ)
- ใช้ ๊ UI ของ Container Station เป็นหลัก - การจัดหมวดหมู่และแท็กเอกสาร
## โครงสร้างโฟลเอร์ (บน QNAP) - การค้นหาเอกสารด้วยคำสำคัญหรือฟิลเอร์
/share/Container/dms/ - การกำหนดสิทธิ์การเข้าถึงเอกสาร (เช่น อ่านอย่างเดียว, แก้ไข, ลบ)
├─ docker-compose.yml # Create โดย UI Container Station - การบันทึกประวัติการใช้งานเอกสาร (Audit Trail)
├─ mariadb/ - การมอบหมายงานให้กับผู้เกี่ยวข้อง และแจ้งเตือนเมื่อมีการมอบหมายงาน
│ ├─ data/ # ข้อมูลจริงของ MariaDB - การแจ้งเตือนเมื่อถึงกำหนดวันที่ต้องส่งเอกสารต่อให้ ผู้เกี่ยวข้องอื่นๆ
│ ├─ init/ # ข้อมูลเริ่มต้นของ MariaDB - การแจ้งเตือนเมื่อมีการเปลี่ยนแปลงเอกสาร
│ │ ├─ 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! ### 👥 0.4 Target Users
│ │ ├─ 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! - พนักงานควบคุมเอกสาร (Document Control)/ ผู้ดูแลระบบขององค์กร (admin)
│ │ ├─ 06_dms_data_v5_1_seed_users.sql # Seed users data here! - ผู้จัดการฝ่ายเอกสาร ขององค์กร
│ │ └─ 07_dms_data_v5_1_seed_contract_dwg.sql # Seed contract drawing data here! - ผู้จัดการโครงการ ขององค์กร
│ └─ my.cnf - คณะกรรมการ ของโครงการ
├─ backend/ - ผู้ดูแลระบบ IT ของโครงการ (superadmin)
│ ├─ app/
│ ├─ src/ ### 📈 0.5 Expected Outcomes
│ │ ├─ db/
│ │ │ └─models/ - ลดเวลาในการค้นหาเอกสารลงอย่างน้อย 50%
│ │ ├─ middleware/ - ลดเวลาในการจัดทำรายงานเอกสาร ประจำวัน, ประจำสัปดาห์, ประจำเดือน, ประจำปี และ รายงานเอกสารทั้งโครงการ
│ │ ├─ routes/ - ลดการใช้เอกสารกระดาษในองค์กร
│ │ ├─ utils/ - เพิ่มความปลอดภัยในการจัดเก็บข้อมูล
│ │ └─ index.js - รองรับการทำงานแบบ Remote Work
│ ├─ Dockerfile
│ ├─ package.json ### 📘 0.6 Requirements Use Cases
│ └─ package-lock.json # ไม่มี
├─ frontend/ #### 📘 Use Case: Upload Document
│ ├─ app/
│ │ ├─ correspondences/ Actor: พนักงานควบคุมเอกสาร (Document Control)
│ │ ├─ dashboard/ Description: พนักงานควบคุมเอกสารสามารถอัปโหลดเอกสารเข้าสู่ระบบเพื่อจัดเก็บและใช้งานในภายหลัง
│ │ ├─ health/ Preconditions: พนักงานควบคุมเอกสารต้องเข้าสู่ระบบก่อน
│ │ ├─ login/ Main Flow:
│ │ └─ users/
│ ├─ public/ พนักงานควบคุมเอกสารเลือกเมนู “อัปโหลดเอกสาร”
│ ├─ Dockerfile เลือกไฟล์จากเครื่องคอมพิวเตอร์
│ ├─ package.json กรอกข้อมูลประกอบ เช่น ชื่อเอกสาร หมวดหมู่ แท็ก
│ ├─ package-lock.json # ไม่มี กดปุ่ม “อัปโหลด”
│ ├─ next.config.js ระบบบันทึกเอกสารและแสดงผลการอัปโหลดสำเร็จ
│ └─ page.jsx
├─ phpmyadmin/ Postconditions: เอกสารถูกจัดเก็บในระบบและสามารถค้นหาได้
│ ├─ sessions/ # โฟลเดอร์เซสชันถาวรของ phpMyAdmin
│ ├─ tmp/ #### 📘 Use Case: Assign Users to Document
│ ├─ config.user.inc.php
│ └─ zzz-custom.ini Actor: พนักงานควบคุมเอกสาร (Document Control)
├─ nginx/ Description: พนักงานควบคุมเอกสารสามารถ มอบหมายงานให้กับ Users
│ ├─ certs/ Preconditions: พนักงานควบคุมเอกสารต้องเข้าสู่ระบบก่อน, เอกสารต้องอัปโหลดเรียบร้อยแล้ว
│ ├─ nginx.conf Main Flow:
│ └─ dms.conf
├─ n8n/ พนักงานควบคุมเอกสารเลือกเมนู “มอบหมายงาน”
├─ n8n-cache/ เลือกเอกสารในระบบ
├─ n8n-postgres/ เลือก Users กำหนดวันสิ้นสุดงาน
└─ logs/ กดปุ่ม “มอบหมายงาน”
├─ backend/ ระบบบันทึกเอกสารและแสดงผลการมอบหมายงานสำเร็จ
├─ frontend/
├─ nginx/ Postconditions: งานที่มอยหมาย จัดเก็บในระบบและสามารถค้นหาได้
├─ pgadmin/
├─ phpmyadmin/ #### 📘 Use Case: Search Document
└─ postgres_n8n/
/share/dms-data # เก็บข้อมมูล .pdf, .dwg แยกตาม correspondences, documents Actor: ผู้ใช้งานทั่วไป
Description: ผู้ใช้งานสามารถค้นหาเอกสารจากระบบด้วยคำสำคัญหรือฟิลเตอร์
Preconditions: ผู้ใช้งานต้องเข้าสู่ระบบ
# ภาษา: ใช้ภาษาไทยในการโต้ตอบ ยกเว้น ศัพท์เทคนิคหรือศัพท์เฉพาะทาง Main Flow:
# ไฟล์ที่ ีupload ผู้ใช้งานกรอกคำค้นหรือเลือกฟิลเตอร์ (หมวดหมู่, วันที่, ผู้สร้าง, ผู้ได้รับมอบหมายงาน, สถานะ, title, subject)
- Dockerfile ของ backend กดปุ่ม “ค้นหา”
- package.json ของ backend ระบบแสดงรายการเอกสารที่ตรงกับเงื่อนไข
- docker-compose.yml ชอง Container station
- nginx.conf, dms.conf ของ nginx Postconditions: ผู้ใช้งานสามารถเปิดดูหรือดาวน์โหลดเอกสารที่ค้นพบได้
- 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! #### 📘 Use Case: Manage Access
- 02_dms_data_v5_1_triggers.sql # Create all triggers here!
- 03_dms_data_v5_1_procedures_handlers.sql # Create all procedures here! Actor: ผู้ดูแลระบบโครงการ (superadmin) / ผู้ดูแลระบบขององค์กร (admin)
- 04_dms_data_v5_1_views.sql # Create all views here! Description: ผู้ดูแลระบบสามารถกำหนดสิทธิ์การเข้าถึงเอกสารให้กับผู้ใช้งาน
- 05 dms_data_v5_1_seeก_data.sql # Seed nescesary data here! Preconditions: ผู้ดูแลระบบต้องเข้าสู่ระบบ
- 06_dms_data_v5_1_seed_users.sql # Seed users data here! Main Flow:
# งานที่ต้องการ: ผู้ดูแลระบบเลือกเอกสาร
- ไม่ใช้ .env เด็ดขาด Container Station ไม่รองรับ และ docker-compose.yml ได้ทดสอบ รันบน Container station มาแล้ว กด “จัดการสิทธิ์”
- Code ของ backend ทั้งหมด เลือกผู้ใช้งานและกำหนดสิทธิ์ (อ่าน, แก้ไข, ลบ)
- การทดสอบระบบ backend ทุกส่วน ให้พร้อม สำหรับ frontend กด “บันทึก”
Postconditions: สิทธิ์การเข้าถึงเอกสารถูกปรับตามที่กำหนด
#### 📘 Use Case: View Document History
Actor: ผู้ใช้งานทั่วไป / ผู้ดูแลระบบ
Description: ผู้ใช้งานสามารถดูประวัติการใช้งานเอกสาร เช่น การแก้ไข การดาวน์โหลด
Preconditions: ผู้ใช้งานต้องมีสิทธิ์เข้าถึงเอกสาร
Main Flow:
ผู้ใช้งานเปิดเอกสาร
เลือก “ดูประวัติ”
ระบบแสดงรายการกิจกรรมที่เกี่ยวข้องกับเอกสาร
Postconditions: ผู้ใช้งานสามารถตรวจสอบการเปลี่ยนแปลงย้อนหลังได้
### 🔄 0.7 Workflow อัตโนมัติในระบบ DMS
✅ ประโยชน์ของ Workflow อัตโนมัติใน DMS
- ลดภาระงานซ้ำ ๆ ของผู้ใช้งาน
- เพิ่มความปลอดภัยและการควบคุมเอกสาร
- เพิ่มความเร็วในการดำเนินงาน
- ลดข้อผิดพลาดจากการทำงานด้วยมือ
#### 🧩 Workflow: 1. Document treat Workflow
กรณี: เมื่อมีการอัปโหลดเอกสารต้องได้รับการมอบหมายงานให้กับ พนักงานภายในองค์กรณ์
ขั้นตอนอัตโนมัติ:
1. ผู้ใช้งานอัปโหลดเอกสารและเลือก “มอบหมายงาน”
2. ระบบส่งแจ้งเตือนไปยังผู้ได้รับมอบหมายงาน
3. ผู้อนุมัติสามารถตรวจสอบและกด “ตรวจสอบแล้ว”
4. ระบบบันทึกสถานะเอกสารและ ส่งต่อ ไปยัง องกรณือื่น ตามลำดับ เมื่อได้ผลและจัดทำเอกสารตอบแล้ว จึงแจ้งผลกลับไปยังผู้ส่ง
#### 📥 Workflow: 2. Auto Tagging & Categorization
กรณี: เอกสารที่อัปโหลดมีชื่อหรือเนื้อหาที่ตรงกับหมวดหมู่ที่กำหนดไว้
ขั้นตอนอัตโนมัติ:
เมื่ออัปโหลดเอกสาร ระบบวิเคราะห์ชื่อไฟล์หรือเนื้อหา
ระบบกำหนดหมวดหมู่และแท็กให้โดยอัตโนมัติ เช่น “ใบเสนอราคา” → หมวด “การเงิน”
ผู้ใช้งานสามารถแก้ไขได้หากต้องการ
#### 🔐 Workflow: 3. Access Control Workflow
กรณี: เอกสารที่มีความลับสูงต้องจำกัดการเข้าถึง
ขั้นตอนอัตโนมัติ:
เมื่ออัปโหลดเอกสารที่มีคำว่า “ลับ” หรือ “Confidential”
ระบบกำหนดสิทธิ์เริ่มต้นให้เฉพาะผู้ใช้งานระดับผู้จัดการขึ้นไป
ระบบแจ้งเตือนผู้ดูแลระบบให้ตรวจสอบสิทธิ์เพิ่มเติม
#### 📤 Workflow: 4. Expiry & Archiving Workflow
กรณี: เอกสารที่มีอายุการใช้งาน เช่น สัญญา หรือใบอนุญาต
ขั้นตอนอัตโนมัติ:
เมื่ออัปโหลดเอกสาร ผู้ใช้งานระบุวันหมดอายุ
ระบบแจ้งเตือนก่อนหมดอายุล่วงหน้า เช่น 30 วัน
เมื่อถึงวันหมดอายุ ระบบย้ายเอกสารไปยังหมวด “Archive” โดยอัตโนมัติ
#### 📊 Workflow: 5. Audit Trail & Notification Workflow
กรณี: มีการแก้ไขหรือดาวน์โหลดเอกสารสำคัญ
ขั้นตอนอัตโนมัติ:
ทุกการกระทำกับเอกสาร (เปิด, แก้ไข, ลบ) จะถูกบันทึกใน Audit Log
หากเอกสารถูกแก้ไขโดยผู้ใช้งานที่ไม่ใช่เจ้าของ ระบบแจ้งเตือนเจ้าของเอกสารทันที
## 🛠️ 1. DMS Architecture Deep Dive (Backend + Frontend)
### 1.1 Executive Summary
- Reverse proxy (Nginx/NPM) เผยแพร่ Frontend (Next.js) และ Backend (Node.js/Express) ผ่าน HTTPS (HSTS)
- Backend เชื่อม MariaDB 10.11 (ข้อมูลหลัก DMS) และแยก n8n + Postgres 16 สำหรับ workflow
- RBAC/ABAC ถูกบังคับใช้งานใน middleware + มีชุด SQL (tables → triggers → procedures → views → seed)
- ไฟล์จริง (PDF/DWG) เก็บนอก webroot ที่ /share/dmsdata พร้อมมาตรฐานการตั้งชื่อ+โฟลเดอร์
- Dev/Prod แยกชัดเจนผ่าน Docker multistage + dockercompose + โฟลเดอร์ persist logs/config/certs
### 1.2 Runtime Topology & Trust Boundaries
```text
Internet Clients (Browser)
│ HTTPS 443 (HSTS) [QNAP mgmt = 8443]
┌─────────────────────────────────────────────────────┐
│ Reverse Proxy Layer │
│ ├─ Nginx (Alpine) or Nginx Proxy Manager (NPM) │
│ ├─ TLS (LE cert; SAN multisubdomain) │
│ └─ Routes: │
│ • /, /_next/* → Frontend (Next.js :3000) │
│ • /api/* → Backend (Express :3001) │
│ • /pma/* → phpMyAdmin │
│ • /n8n/* → n8n (Workflows) │
└─────────────────────────────────────────────────────┘
│ │
│ └──────────┐
▼ │
Frontend (Next.js) │
│ Cookie-based Auth (HttpOnly) │
▼ ▼
Backend (Node/Express ESM) ─────────► MariaDB 10.11
│ │
└────────────────────────────────────┘
Project data (.pdf/.dwg) @ /share/dms-data
n8n (workflows) ──► Postgres 16 (separate DB for automations)
```
==Trust Boundaries==
- Public zone: Internet ↔ Reverse proxy
- App zone: Reverse proxy ↔ FE/BE containers (internal Docker network)
- # Data zone: Backend ↔ Databases (MariaDB, Postgres) + /share/dms-data
### 1.3 Frontend: Next.js (ESM) / React.js
#### 1.3.1 Stack & Key libs
- Next.js (App Router), React, ESM
- Tailwind CSS, PostCSS, shadcn/ui (components.json)
- Fetch API (credentials include) → Cookie Auth (HttpOnly)
#### 1.3.2 Directory Layout
```text
/frontend/
├─ app/
│ ├─ login/
│ ├─ dashboard/
│ ├─ users/
│ ├─ correspondences/
│ ├─ health/
│ └─ layout.tsx / page.tsx (ตาม App Router)
├─ public/
├─ Dockerfile (multi-stage: dev/prod)
├─ package.json
├─ next.config.js
└─ ...
```
#### 1.3.3 Routing & Layouts
- Public /login, /health
- Protected: /dashboard, /users, /correspondences, ... (client-side guard)
- เก็บ middleware.ts (ของเดิม) เพื่อหลีกเลี่ยง regression; ใช้ clientguard + server action อย่างระมัดระวัง
#### 1.3.4 Auth Flow (Cookie-based)
1. ผู้ใช้ submit form /login → POST /api/auth/login (Backend)
2. Backend set HttpOnly cookie (JWT) + SameSite=Lax/Strict, Secure
3. หน้า protected เรียก GET /api/auth/me เพื่อตรวจสอบสถานะ
4. หาก 401 → redirect → /login
**CORS/Fetch**: เเปิด credentials: 'include' ทุกครั้ง, ตั้ง NEXT_PUBLIC_API_BASE เป็น origin ของ backend ผ่าน proxy (เช่น https://lcbp3.np-dms.work)
#### 1.3.5 UI/UX
- Seablue palette, sidebar พับได้, cardbased KPI
- ตารางข้อมูลเตรียมรองรับ serverside DataTables\*\*
- shadcn/ui: Button, Card, Badge, Tabs, Dropdown, Tooltip, Switch, etc.
#### 1.3.6 Config & ENV
- NEXT_PUBLIC_API_BAS (ex: https://lcbp3.np-dms.work)
- Build output แยก dev/prod; ระวัง EACCES บน QNAP → ใช้ user node + ปรับสิทธิ์โวลุ่ม .next/\*
#### 1.3.7 Error Handling & Observability (FE)
- Global error boundary (app router) + toast/alert patterns
- Network layer: แยก handler สำหรับ 401/403/500 + retry/backoff ที่จำเป็น
- Metrics (optional): webvitals, UX timing (เก็บฝั่ง n8n หรือ simple logging)
---
### 1.4 Backend Architecture (Node.js ESM / Express)
#### 1.4.1 Stack & Structure
- Node 20.x, ESM modules, Express\*\*
- mysql2/promise, jsonwebtoken, cookie-parser, cors, helmet, winston/morgan
```text
/backend/
├─ src/
│ ├─ index.js # bootstrap server, CORS, cookies, health
│ ├─ routes/
│ │ ├─ auth.js # /api/auth/* (login, me, logout)
│ │ ├─ users.js # /api/users/*
│ │ ├─ correspondences.js # /api/correspondences/*
│ │ ├─ drawings.js # /api/drawings/*
│ │ ├─ rfas.js # /api/rfas/*
│ │ └─ transmittals.js # /api/transmittals/*
│ ├─ middleware/
│ │ ├─ authGuard.js # verify JWT from cookie
│ │ ├─ requirePermission.js# RBAC/ABAC enforcement
│ │ ├─ errorHandler.js
│ │ └─ requestLogger.js
│ ├─ db/
│ │ ├─ pool.js # createPool, sane defaults
│ │ └─ models/ # query builders (User, Drawing, ...)
│ ├─ utils/
│ │ ├─ hash.js (bcrypt/argon2)
│ │ ├─ jwt.js
│ │ ├─ pagination.js
│ │ └─ responses.js
│ └─ config/
│ └─ index.js # env, constants
├─ Dockerfile
└─ package.json
```
#### 1.4.2 Request Lifecycle
1. helmet + cors (allow specific origin; credentials true)
2. cookie-parser, json limit (e.g., 2MB)
3. requestLogger → trace + response time
4. Route handler → authGuard (protected) → requirePermission (perroute) → Controller
5. Error bubbles → errorHandler (JSON shape, status map)
#### 1.4.3 Auth & RBAC/ABAC
- JWT ใน HttpOnly cookie; Claims: sub (user_id), roles, exp
- authGuard: ตรวจ token → แนบ req.user
- requirePermission: เช็ค permission ตามเส้นทาง/วิธี; แผนขยาย ABAC (เช่น project scope, owner, doc state)
- Roles/Permissions ถูก seed ใน SQL; มี view เมทริกซ์ เพื่อ debug (เช่น v_role_permission_matrix)
\*\*ตัวอย่าง pseudo requirePermission(permission)
```js
export const requirePermission = (perm) => async (req, res, next) => {
if (!req.user) return res.status(401).json({ error: "Unauthenticated" });
const ok = await checkPermission(req.user.user_id, perm, req.context);
if (!ok) return res.status(403).json({ error: "Forbidden" });
return next();
};
```
#### 1.4.4 Database Access & Pooling
- createPool({ connectionLimit: 10~25, queueLimit: 0, waitForConnections: true })
- ใช้ parameterized queries เสมอ; ปรับ sql_mode ที่จำเป็นใน my.cnf
#### 1.4.5 File Storage & Secure Download
- Root: /share/dmsdata
- โครงโฟลเดอร์: {module}/{yyyy}/{mm}/{entityId}/ + ชื่อไฟล์ตามมาตรฐาน (เช่น DRW-code-REV-rev.pdf)
- Endpoint download: ตรวจสิทธิ์ (RBAC/ABAC) → res.sendFile()/stream; ป้องกัน path traversal
- MIME allowlist + size limit + virus scan (optional; ภายหลัง)
#### 1.4.6 Health & Readiness
- GET /api/health → { ok: true }
- (optional) /api/ready ตรวจ DB ping + disk space (dmsdata)
#### 1.4.7 Config & ENV (BE)
- DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME
- JWT_SECRET, COOKIE_NAME, COOKIE_SAMESITE, COOKIE_SECURE
- CORS_ORIGIN, LOG_LEVEL, APP_BASE_URL
- FILE_ROOT=/share/dms-data
#### 1.4.8 Logging
- Access log (morgan) + App log (winston) → /share/Container/dms/logs/backend/
- รูปแบบ JSON (timestamp, level, msg, reqId) + daily rotation (logrotate/containerside)
### 1.5 Database (MariaDB 10.11)
#### 1.5.1 Schema Overview (ย่อ)
- RBAC core: users, roles, permissions, user_roles, role_permissions
- Domain: drawings, contracts, correspondences, rfas, transmittals, organizations, projects, ...
- Audit: audit_logs (แผนขยาย), deleted_at (soft delete, แผนงาน)
```text
[users]──<user_roles>──[roles]──<role_permissions>──[permissions]
└── activities/audit_logs (future expansion)
[drawings]──<mapping>──[contracts]
[rfas]──<links>──[drawings]
[correspondences] (internal/external flag)
```
#### 1.5.2 Init SQL Pipeline
1. 01\_\*\_deploy_table_rbac.sql — สร้างตารางหลักทั้งหมด + RBAC
2. 02\_\*\_triggers.sql — บังคับ data rules, autoaudit fields
3. 03\_\*\_procedures_handlers.sql — upsert/bulk handlers (เช่น sp_bulk_import_contract_dwg)
4. 04\_\*\_views.sql — รายงาน/เมทริกซ์สิทธิ์ (v_role_permission_matrix, etc.)
5. 05\_\*\_seed_data.sql — ค่าพื้นฐาน domain (project, categories, statuses)
6. 06\_\*\_seed_users.sql — บัญชีเริ่มต้น (superadmin, editors, viewers)
7. 07\_\*\_seed_contract_dwg.sql — ข้อมูลตัวอย่างแบบสัญญา
#### 1.5.3 Indexing & Performance
- Composite indexes ตามคอลัมน์ filter/sort (เช่น (project_id, updated_at DESC))
- Fulltext index (optional) สำหรับ advanced search
- Query plan review (EXPLAIN) + เพิ่ม covering index ตามรายงาน
#### 1.5.4 MySQL/MariaDB Config (my.cnf — แนวทาง)
```conf
[mysqld]
innodb_buffer_pool_size = 4G # ปรับตาม RAM/QNAP
innodb_log_file_size = 512M
innodb_flush_log_at_trx_commit = 1
max_connections = 200
sql_mode = STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
```
> ปรับค่าให้เหมาะกับ workload จริง + เฝ้าดู IO/CPU ของ QNAP
#### 1.5.5 Backup/Restore
- Logical backup: mysqldump --routines --triggers --single-transaction
- Physical (snapshot QNAP) + schedule ผ่าน n8n/cron
- เก็บสำเนา offNAS (encrypted)
### 1.6 Reverse Proxy & TLS
#### 1.6.1 Nginx (Alpine) — ตัวอย่าง server block
> สำคัญ: บนสภาพแวดล้อมนี้ ให้ใช้คนละบรรทัด:
> listen 443 ssl;
> http2 on;
> หลีกเลี่ยง listen 443 ssl http2;
```conf
server {
listen 80;
server_name lcbp3.np-dms.work;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name lcbp3.np-dms.work;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; preload" always;
# Frontend
location / {
proxy_pass http://frontend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Next.js static
location /_next/ {
proxy_pass http://frontend:3000;
}
# Backend API
location /api/ {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://backend:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
# phpMyAdmin (sub-path)
location /pma/ {
proxy_pass http://phpmyadmin:80/;
}
# n8n
location /n8n/ {
proxy_pass http://n8n:5678/;
}
}
```
#### 1.6.2 Nginx Proxy Manager (NPM) — Tips
- ระวังอย่าใส่ proxy_http_version ซ้ำซ้อน (duplicate directive) ใน Advanced
- ถ้าต้องแก้ไฟล์ด้านใน NPM → ระวังไฟล์ใน /data/nginx/proxy_host/\*.conf
- จัดการ certificate / SAN หลาย subdomain ใน UI แต่ mainten ดีเรื่อง symlink/renew
#### 1.6.3 TLS & Certificates
- Lets Encrypt (HTTP01 webroot/standalone) + HSTS
- QNAP mgmt เปลี่ยนเป็น 8443 → พอร์ต 443 public ว่างสำหรับ Nginx/NPM
### 1.7 Docker Compose Topology
#### 1.7.1 Services (สรุป)
- frontend (Next.js) :3000
- backend (Express) :3001
- mariadb (10.11) :3306 (internal)
- phpmyadmin :80 (internal)
- nginx or npm :80/443 (published)
- n8n :5678 (internal)
- postgres_n8n (16-alpine)
- pgadmin4
#### 1.7.2 Volumes & Paths
```text
/share/Container/dms/
├─ mariadb/data
├─ mariadb/init/*.sql
├─ backend/ (code)
├─ frontend/ (code)
├─ phpmyadmin/{sessions,tmp,config.user.inc.php}
├─ nginx/{nginx.conf,dms.conf,certs/}
├─ n8n, n8n-postgres, n8n-cache
└─ logs/{backend,frontend,nginx,pgadmin,phpmyadmin,postgres_n8n}
/share/dms-data (pdf/dwg storage)
```
#### 1.7.3 Healthchecks (suggested)
- backend:
```sh
curl http://localhost:3001/api/health
```
- frontend: curl /health (simple JSON)
- mariadb: mysqladmin ping with credentials
- nginx: nginx -t at startup
#### 1.7.4 Security Hardening
- รัน container ด้วย user nonroot (user: node สำหรับ FE/BE)
- จำกัด capabilities; readonly FS (ยกเว้นโวลุ่มจำเป็น)
- เฉพาะ backend เมานต์ /share/dms-data
### 1.8 Observability, Ops, and Troubleshooting
#### 1.8.1 Logs
- Frontend → /logs/frontend/\*
- Backend → /logs/backend/\* (app/access/error)
- Nginx/NPM → /logs/nginx/\*
- MariaDB → default datadir log + slow query (เปิดใน my.cnf หากต้องการ)
#### 1.8.2 Common Issues & Playbooks
- 401 Unauthenticated: ตรวจ authGuard → JWT cookie มี/หมดอายุ → เวลา server/FE sync → CORS credentials: true
- EACCES Next.js: สิทธิ์ .next/\* + run as`node, โวลุ่ม map ถูก user:group
- NPM duplicate directive: ลบซ้ำ proxy_http_version ใน Advanced / ตรวจ proxy_host/\*.conf
- LE cert path/symlink: ตรวจ /etc/letsencrypt/live/npm-\* symlink ชี้ถูก
- DB field not found: ตรวจ schema vs code (migration/init SQL) → sync ให้ตรง
#### 1.8.3 Performance Guides
- Backend: keepalive, gzip/deflate at proxy, pool 1025, paginate, avoid N+1
- Frontend: prefetch critical routes, cache static, image optimization
- DB: เพิ่ม index จุด filter, analyze query (EXPLAIN), ปรับ buffer pool
### 1.9 Security & Compliance
- HTTPS only + HSTS (preload)
- CORS: allow list เฉพาะ FE origin; Access-Control-Allow-Credentials: true
- Cookie: HttpOnly, Secure, SameSite=Lax/Strict
- Input Validation: celebrate/zod (optional) + sanitize
- Rate limiting: per IP/route (optional)
- AuditLog: วางแผนเพิ่ม ครอบคลุม CRUD + mapping (actor, action, entity, before/after)
- Backups: DB + /share/dms-data + config (encrypted offNAS)
### 1.10 Backlog → Architecture Mapping
1. RBAC Enforcement ครบ → เติม requirePermission ทุก route + test matrix ผ่าน view
2. AuditLog ครบ CRUD/Mapping → trigger + table audit_logs + BE hook
3. Upload/Download จริงของ Drawing Revisions → BE endpoints + virus scan (optional)
4. Dashboard KPI → BE summary endpoints + FE cards/charts
5. Serverside DataTables → paging/sort/filter + indexesรองรับ
6. รายงาน Export CSV/Excel/PDF → BE export endpoints + FE buttons
7. Soft delete (deleted_at) → BE filter default scope + restore endpoint
8. Validation เข้ม → celebrate/zod schema + consistent error shape
9. Indexing/Perf → slow query log + EXPLAIN review
10. Job/Cron Deadline Alerts → n8n schedule + SMTP
### 1.11 Port & ENV Matrix (Quick Ref)
| Component | Ports | Key ENV |
| Nginx/NPM | 80/443 (public) | SSL paths, HSTS |
| Frontend | 3000 (internal) | NEXT*PUBLIC_API_BASE |
| Backend | 3001 (internal) | DB*\*, JWT*SECRET, CORS_ORIGIN, FILE_ROOT |
| MariaDB | 3306 (internal) | MY_CNF, credentials |
| n8n | 5678 (internal) | N8N*, webhook URL under /n8n/ |
| Postgres | 5432 (internal) | n8n DB |
QNAP mgmt: 8443 (already moved)
### 1.12 Sample Snippets
#### 1.12.1 Backend CORS (credentials)
```js
app.use(
cors({
origin: ["https://lcbp3.np-dms.work"],
credentials: true,
})
);
```
#### 1.12.2 Secure Download (guarded)
```js
router.get(
"/files/:module/:id/:filename",
authGuard,
requirePermission("file.read"),
async (req, res) => {
const { module, id, filename } = req.params;
// 1) ABAC: verify user can access this module/entity
const ok = await canReadFile(req.user.user_id, module, id);
if (!ok) return res.status(403).json({ error: "Forbidden" });
const abs = path.join(FILE_ROOT, module, id, filename);
if (!abs.startsWith(FILE_ROOT))
return res.status(400).json({ error: "Bad path" });
return res.sendFile(abs);
}
);
```
#### 1.12.3 Healthcheck
```js
router.get("/health", (req, res) => res.json({ ok: true }));
```
### 13 Deployment Workflow (Suggested)
1. Git (Gitea) branch strategy feature/\* → PR → main
2. Build images (dev/prod) via Dockerfile multistage; pin Node/MariaDB versions
3. docker compose up -d --build จาก /share/Container/dms
4. Validate: /health, /api/health, login roundtrip
5. Monitor logs + baseline perf; run SQL smoke tests (views/triggers/procs)
### 14 Appendix
- Naming conventions: snake_case DB, camelCase JS
- Timezones: store UTC in DB; display in app TZ (+07:00)
- Character set: UTF8 (utf8mb4_unicode_ci)
- Large file policy: size limit (e.g., 50200MB), allowlist extensions
- Retention: archive strategy for old revisions (optional)
## บทบาท: คุณคือ Programmer และ Document Engineer ที่เชี่ยวชาญ
1. การพัฒนาเว็บแอป (Web Application Development)
2. Configuration of Container Station on QNAP
3. Database: mariadb:10.11
4. Database management: phpmyadmin:5-apache
5. Backend: node:.js (ESM)
6. Frontend: next.js, react
7. Workflow automation: n8n:
8. Workflow database: postgres:16-alpine
9. Workflow database management: pgadmin4
10. Reverse proxy: nginx:1.27-alpine
11. linux on QNAP
12. การจัดการฐานข้อมูล (Database Management)
13. การวิเคราะห์ฐานข้อมูล (Database Analysis)
14. การจัดการฐานข้อมูลเชิงสัมพันธ์ (Relational Databases)
15. ภาษา SQL
16. RBAC
## 2. ระบบที่ใช้
## Server
- ใช้ Container Station เป็น SERVER บน QNAP (Model: TS-473A, RAM: 32GB, CPU: AMD Ryzen V1500B 4 cores 8 threads) **เปลี่ยน port 443 ของ QNAP เป็น 8443 แล้ว**
## การพัฒนาโครงการ
- ด้วย Visual Studio Code บน Windows 11
- ใช้ ๊ UI ของ Container Station เป็นหลัก
## โครงสร้างโฟลเดอร์ (บน QNAP)
/share/Container/dms/
├─ docker-compose.yml # Create โดย UI Container Station
├─ mariadb/
│ ├─ data/ # ข้อมูลจริงของ MariaDB
│ ├─ init/ # ข้อมูลเริ่มต้นของ MariaDB
│ │ ├─ 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!
│ │ └─ 07_dms_data_v5_1_seed_contract_dwg.sql # Seed contract drawing data here!
│ └─ my.cnf
├─ backend/
│ ├─ app/
│ ├─ src/
│ │ ├─ db/
│ │ │ └─models/
│ │ ├─ middleware/
│ │ ├─ routes/
│ │ ├─ utils/
│ │ └─ index.js
│ ├─ Dockerfile
│ ├─ package.json
│ └─ package-lock.json # ไม่มี
├─ frontend/
│ ├─ app/
│ │ ├─ correspondences/
│ │ ├─ dashboard/
│ │ ├─ health/
│ │ ├─ login/
│ │ └─ users/
│ ├─ public/
│ ├─ Dockerfile
│ ├─ package.json
│ ├─ package-lock.json # ไม่มี
│ ├─ next.config.js
│ └─ page.jsx
├─ phpmyadmin/
│ ├─ sessions/ # โฟลเดอร์เซสชันถาวรของ phpMyAdmin
│ ├─ tmp/
│ ├─ config.user.inc.php
│ └─ zzz-custom.ini
├─ nginx/
│ ├─ certs/
│ ├─ nginx.conf
│ └─ dms.conf
├─ n8n/
├─ n8n-cache/
├─ n8n-postgres/
└─ logs/
├─ backend/
├─ frontend/
├─ nginx/
├─ pgadmin/
├─ phpmyadmin/
└─ postgres_n8n/
/share/dms-data # เก็บข้อมมูล .pdf, .dwg แยกตาม correspondences, documents
# งานที่ต้องการ:
- ไม่ใช้ .env เด็ดขาด Container Station ไม่รองรับ และ docker-compose.yml ได้ทดสอบ รันบน Container station มาแล้ว
- Code ของ backend ทั้งหมด
- การทดสอบระบบ backend ทุกส่วน ให้พร้อม สำหรับ frontend
# กรณี 2: มี Git อยู่แล้ว (มี main อยู่)
2.1 อัปเดต main ให้ตรงล่าสุดก่อนแตกบร้านช์
cd /share/Container/dms
git checkout main
git pull --ff-only # ถ้าเชื่อม remote อยู่
git tag -f stable-$(date +%F) # tag จุดเสถียรปัจจุบัน
2.2 แตก branch งาน Dashboard
git checkout -b feature/dashboard-update-$(date +%y%m%d)
git checkout -b feature/dashboard-update-251004
2.3 ทำงาน/คอมมิตตามปกติ
# แก้ไฟล์ frontend/app/dashboard/\* และที่เกี่ยวข้อง
git add frontend/app/dashboard
git commit -m "feat(dashboard): เพิ่มส่วนจัดการ user"
git push -u origin feature/dashboard-update-251004

96
b.env
View File

@@ -1,96 +0,0 @@
TZ=Asia/Bangkok
GENERIC_TIMEZONE=Asia/Bangkok
PUBLIC_DOMAIN=np-dms.work
PUBLIC_FRONTEND_URL=https://lcbp3.np-dms.work
PUBLIC_BACKEND_URL=https://lcbp3.np-dms.work/api
PUBLIC_N8N_URL=https://lcbp3.np-dms.work/n8n
MARIADB_HOST=mariadb
MARIADB_PORT=3306
MARIADB_ROOT_PASSWORD=Center#2025
MARIADB_DATABASE=dms
MARIADB_USER=center
MARIADB_PASSWORD=Center#2025
# MARIADB_HOST_PORT=7307
# BACKEND_HOST_PORT=7001
# FRONTEND_HOST_PORT=7000
# PHPMYADMIN_HOST_PORT=7070
NGINX_HTTP_HOST_PORT=80
NGINX_HTTPS_HOST_PORT=443
N# 8N_HOST_PORT=7081
NODE_ENV=production
JWT_SECRET=8b0df02e4aee9f9f79a4f2d8ba77b0b82c1ee3446b68cb0bae94ab54d60f8d9e
JWT_EXPIRES_IN=12h
PASSWORD_SALT_ROUNDS=10
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX=200
CORS_ORIGINS=https://lcbp3.np-dms.work,http://localhost:7000,http://192.168.20.248:7000
NEXT_TELEMETRY_DISABLED=1
PMA_HOST=mariadb
PMA_PORT=3306
PMA_ABSOLUTE_URI=https://lcbp3.np-dms.work.com/pma/
UPLOAD_LIMIT=256M
MEMORY_LIMIT=512M
NGINX_SERVER_NAME=np-dms.work.com
NGINX_PROXY_READ_TIMEOUT=300
# QNAP_SSL_CERT_HOST=/etc/qnap-ssl/combine
# QNAP_SSL_KEY_HOST=/etc/qnap-ssl/key
# NGINX_SSL_CERT=/etc/nginx/certs/fullchain.pem
# NGINX_SSL_KEY=/etc/nginx/certs/privkey.pem
# NGINX_SSL_KEY=/etc/nginx/certs
QNAP_SSL_CERT=/etc/config/QcloudSSLCertificate/cert
NGINX_SSL_CERT=/etc/qnap-ssl
N8N_BASIC_AUTH_ACTIVE=true
N8N_BASIC_AUTH_USER=n8n
N8N_BASIC_AUTH_PASSWORD=Center#2025
N8N_PATH=/n8n/
N8N_PROTOCOL=https
N8N_PROXY_HOPS=1
N8N_SECURE_COOKIE=true
N8N_HOST=dcs.mycloudnas.com
N8N_PORT=5678
N8N_EDITOR_BASE_URL=https://lcbp3.np-dms.work/n8n/
WEBHOOK_URL=https://lcbp3.np-dms.work/n8n/
N8N_ENCRYPTION_KEY=9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI
# --- n8n → MariaDB ---
# DB_TYPE=mysqldb
# DB_MYSQLDB_HOST=mariadb
# DB_MYSQLDB_PORT=3306
# DB_MYSQLDB_DATABASE=n8n
# DB_MYSQLDB_USER=n8n_user
# DB_MYSQLDB_PASSWORD=Center#2025 # เปลี่ยนเป็นรหัสแข็งแรงของคุณ
# ==== n8n → PostgreSQL (แทน MariaDB/MySQL) ====
DB_TYPE=postgresdb
DB_POSTGRESDB_HOST=postgres_n8n
DB_POSTGRESDB_PORT=5432
DB_POSTGRESDB_DATABASE=n8n
DB_POSTGRESDB_USER=n8n
DB_POSTGRESDB_PASSWORD=Center#2025
# path โฟลเดอร์ n8n เดิม (มี database.sqlite)
# HOST_N8N=/share/Container/dms/n8n
HOST_BASE=/share/Container/dms
HOST_MARIADB=${HOST_BASE}/mariadb
HOST_BACKEND=${HOST_BASE}/backend
HOST_FRONTEND=${HOST_BASE}/frontend
HOST_PHPMYADMIN=${HOST_BASE}/phpmyadmin
HOST_NGINX=${HOST_BASE}/nginx
HOST_LOGS=${HOST_BASE}/logs
HOST_SCRIPTS=${HOST_BASE}/scripts
HOST_N8N=/share/Container/dms/n8n
HOST_N8N_CACHE=${HOST_BASE}/n8n-cache
HOST_DATA=/share/dms-data
# BACKEND_LOG_DIR=${HOST_LOGS}/backend
BACKEND_LOG_DIR=/app/logs

View File

@@ -1,60 +1,60 @@
# STAGE 1: build - สร้าง stage พื้นฐานสำหรับติดตั้ง dependencies ทั้งหมด # STAGE 1: build - สร้าง stage พื้นฐานสำหรับติดตั้ง dependencies ทั้งหมด
# เราจะใช้ stage นี้เป็น cache ร่วมกันระหว่าง development และ production เพื่อความรวดเร็ว # เราจะใช้ stage นี้เป็น cache ร่วมกันระหว่าง development และ production เพื่อความรวดเร็ว
FROM node:20-alpine AS build FROM node:20-alpine AS build
# USER node # USER node
WORKDIR /app WORKDIR /app
# สร้าง user ไม่ใช่ root (ปลอดภัยขึ้น) # สร้าง user ไม่ใช่ root (ปลอดภัยขึ้น)
# RUN addgroup -S dms && adduser -S dms -G dms # RUN addgroup -S dms && adduser -S dms -G dms
# runtime tools + build deps ชั่วคราว (สำหรับ bcrypt ฯลฯ) # runtime tools + build deps ชั่วคราว (สำหรับ bcrypt ฯลฯ)
#RUN apk add --no-cache curl \ #RUN apk add --no-cache curl \
# && apk add --no-cache --virtual build-deps python3 make g++ # && apk add --no-cache --virtual build-deps python3 make g++
RUN apk add --no-cache --virtual build-deps python3 make g++ RUN apk add --no-cache --virtual build-deps python3 make g++
# COPY --chown=node:node package*.json package-lock.json* ./ # COPY --chown=node:node package*.json package-lock.json* ./
# COPY package*.json package-lock.json* ./ # COPY package*.json package-lock.json* ./
# COPY package.json ./ # COPY package.json ./
# RUN (npm ci --omit=dev || npm install --omit=dev) # RUN (npm ci --omit=dev || npm install --omit=dev)
# ติดตั้ง deps แบบ clean + ติดตั้ง dev tooling ที่จำเป็น # ติดตั้ง deps แบบ clean + ติดตั้ง dev tooling ที่จำเป็น
# RUN npm ci --include=dev || npm install --include=dev && \ # RUN npm ci --include=dev || npm install --include=dev && \
# npx --yes nodemon --version > /dev/null 2>&1 || npm i -D nodemon # npx --yes nodemon --version > /dev/null 2>&1 || npm i -D nodemon
# RUN npm ci # RUN npm ci
RUN npm install RUN npm install
# คัดลอกโค้ด + ตั้ง owner/สิทธิ์ # คัดลอกโค้ด + ตั้ง owner/สิทธิ์
# COPY --chown=app:app src ./src # COPY --chown=app:app src ./src
# ไม่ COPY src เข้ามา — เราจะใช้ bind mount แทน # ไม่ COPY src เข้ามา — เราจะใช้ bind mount แทน
# เพื่อ hot-reload จากโค้ดบน QNAP ได้ทันที # เพื่อ hot-reload จากโค้ดบน QNAP ได้ทันที
# ลบ build deps ลดขนาดอิมเมจ # ลบ build deps ลดขนาดอิมเมจ
# RUN apk del --no-network build-deps # RUN apk del --no-network build-deps
# STAGE 2: development - สำหรับการพัฒนาใน local โดยเฉพาะ # STAGE 2: development - สำหรับการพัฒนาใน local โดยเฉพาะ
# stage นี้จะใช้ dependencies ทั้งหมดจาก 'base' # stage นี้จะใช้ dependencies ทั้งหมดจาก 'base'
FROM base AS development FROM base AS development
# (ต้องมี script "dev" ใน package.json, เช่น "dev": "nodemon src/index.js") # (ต้องมี script "dev" ใน package.json, เช่น "dev": "nodemon src/index.js")
CMD ["npm", "run", "dev"] CMD ["npm", "run", "dev"]
# ---------- Runtime stage ---------- # ---------- Runtime stage ----------
FROM node:20-alpine AS production FROM node:20-alpine AS production
WORKDIR /app WORKDIR /app
# สร้าง user และ group ที่ไม่ใช่ root สำหรับรันแอปพลิเคชัน # สร้าง user และ group ที่ไม่ใช่ root สำหรับรันแอปพลิเคชัน
RUN addgroup -S dms && adduser -S dms -G dms RUN addgroup -S dms && adduser -S dms -G dms
# COPY --from=build /app /app # COPY --from=build /app /app
ENV NODE_ENV=production ENV NODE_ENV=production
# คัดลอกไฟล์ package.json และ node_modules จาก stage 'base' # คัดลอกไฟล์ package.json และ node_modules จาก stage 'base'
COPY --from=build /app/package*.json ./ COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/node_modules ./node_modules
# ลบ devDependencies ที่ไม่จำเป็นสำหรับ production ออก # ลบ devDependencies ที่ไม่จำเป็นสำหรับ production ออก
RUN npm prune --production RUN npm prune --production
# เปลี่ยนไปใช้ user ที่ไม่ใช่ root # เปลี่ยนไปใช้ user ที่ไม่ใช่ root
USER dms USER dms
EXPOSE 3001 EXPOSE 3001
CMD ["npm","start"] CMD ["npm","start"]
# backend/Dockerfile (Node.js ESM) # backend/Dockerfile (Node.js ESM)

View File

@@ -1,34 +1,34 @@
FROM node:20-alpine FROM node:20-alpine
# สำหรับอ่านค่า .env ที่วางไว้ระดับ compose (ไม่ copy เข้า image) # สำหรับอ่านค่า .env ที่วางไว้ระดับ compose (ไม่ copy เข้า image)
ENV NODE_ENV=production ENV NODE_ENV=production
ENV TZ=Asia/Bangkok ENV TZ=Asia/Bangkok
WORKDIR /app WORKDIR /app
# สร้าง user ไม่ใช่ root (ปลอดภัยขึ้น) # สร้าง user ไม่ใช่ root (ปลอดภัยขึ้น)
RUN addgroup -S app && adduser -S app -G app RUN addgroup -S app && adduser -S app -G app
# runtime tools + build deps ชั่วคราว (สำหรับ bcrypt ฯลฯ) # runtime tools + build deps ชั่วคราว (สำหรับ bcrypt ฯลฯ)
RUN apk add --no-cache curl \ RUN apk add --no-cache curl \
&& apk add --no-cache --virtual build-deps python3 make g++ && apk add --no-cache --virtual build-deps python3 make g++
# ติดตั้ง deps ของ npm (เช่น bcrypt ต้องมี python3/make/g++) # ติดตั้ง deps ของ npm (เช่น bcrypt ต้องมี python3/make/g++)
# ใช้ virtual package ชื่อ build-deps (ไม่ต้องมีจุด) # ใช้ virtual package ชื่อ build-deps (ไม่ต้องมีจุด)
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN (npm ci --omit=dev || npm install --omit=dev) RUN (npm ci --omit=dev || npm install --omit=dev)
# คัดลอกโค้ด + ตั้ง owner/สิทธิ์ # คัดลอกโค้ด + ตั้ง owner/สิทธิ์
COPY --chown=app:app src ./src COPY --chown=app:app src ./src
# COPY src ./src # COPY src ./src
# COPY app ./app # COPY app ./app
# เตรียม logs + สิทธิ์อ่านไฟล์ใน /app # เตรียม logs + สิทธิ์อ่านไฟล์ใน /app
RUN mkdir -p /app/logs \ RUN mkdir -p /app/logs \
&& chown -R app:app /app/logs \ && chown -R app:app /app/logs \
&& chmod -R a+rX /app && chmod -R a+rX /app
# ลบ build deps ลดขนาดอิมเมจ # ลบ build deps ลดขนาดอิมเมจ
RUN apk del --no-network build-deps RUN apk del --no-network build-deps
EXPOSE 3001 EXPOSE 3001
USER app USER app
CMD ["node", "src/index.js"] CMD ["node", "src/index.js"]
# backend/Dockerfile (Node.js ESM) # backend/Dockerfile (Node.js ESM)

View File

@@ -1,69 +1,69 @@
# syntax=docker/dockerfile:1.6 # syntax=docker/dockerfile:1.6
########## Base (apk + common tools ติดตั้งตอน build) ########## ########## Base (apk + common tools ติดตั้งตอน build) ##########
FROM node:20-alpine AS base FROM node:20-alpine AS base
WORKDIR /app WORKDIR /app
RUN apk add --no-cache bash curl tzdata python3 make g++ \ RUN apk add --no-cache bash curl tzdata python3 make g++ \
&& ln -snf /usr/share/zoneinfo/Asia/Bangkok /etc/localtime \ && ln -snf /usr/share/zoneinfo/Asia/Bangkok /etc/localtime \
&& echo "Asia/Bangkok" > /etc/timezone && echo "Asia/Bangkok" > /etc/timezone
ENV TZ=Asia/Bangkok APP_HOME=/app RUNTIME_HOME=/opt/runtime ENV TZ=Asia/Bangkok APP_HOME=/app RUNTIME_HOME=/opt/runtime
########## Deps สำหรับ Production (no devDeps) ########## ########## Deps สำหรับ Production (no devDeps) ##########
FROM base AS deps-prod FROM base AS deps-prod
WORKDIR /work WORKDIR /work
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev || npm install --omit=dev RUN npm ci --omit=dev || npm install --omit=dev
RUN mkdir -p ${RUNTIME_HOME} && mv node_modules ${RUNTIME_HOME}/node_modules RUN mkdir -p ${RUNTIME_HOME} && mv node_modules ${RUNTIME_HOME}/node_modules
########## Deps สำหรับ Development (รวม devDeps) ########## ########## Deps สำหรับ Development (รวม devDeps) ##########
FROM base AS deps-dev FROM base AS deps-dev
RUN apk add --no-cache git openssh-client ca-certificates RUN apk add --no-cache git openssh-client ca-certificates
WORKDIR /work WORKDIR /work
COPY package*.json ./ COPY package*.json ./
RUN npm ci || npm install RUN npm ci || npm install
RUN mkdir -p ${RUNTIME_HOME} && mv node_modules ${RUNTIME_HOME}/node_modules RUN mkdir -p ${RUNTIME_HOME} && mv node_modules ${RUNTIME_HOME}/node_modules
########## Runtime: Development ########## ########## Runtime: Development ##########
FROM base AS dev FROM base AS dev
WORKDIR /app WORKDIR /app
# ทำงานเป็น root ชั่วคราวเพื่อจัดสิทธิ์/ลิงก์ แล้วค่อยเปลี่ยนเป็น node # ทำงานเป็น root ชั่วคราวเพื่อจัดสิทธิ์/ลิงก์ แล้วค่อยเปลี่ยนเป็น node
# 1) คัดลอก deps dev # 1) คัดลอก deps dev
COPY --from=deps-dev /opt/runtime/node_modules /opt/runtime/node_modules COPY --from=deps-dev /opt/runtime/node_modules /opt/runtime/node_modules
# 2) สร้าง symlink /app/node_modules → /opt/runtime/node_modules (กันปัญหา NODE_PATH/permission) # 2) สร้าง symlink /app/node_modules → /opt/runtime/node_modules (กันปัญหา NODE_PATH/permission)
RUN ln -sfn /opt/runtime/node_modules /app/node_modules \ RUN ln -sfn /opt/runtime/node_modules /app/node_modules \
&& chown -R node:node /app && chown -R node:node /app
# 3) ใส่สคริปต์ start-dev แล้วค่อยสลับ USER # 3) ใส่สคริปต์ start-dev แล้วค่อยสลับ USER
COPY --chown=node:node ./start-dev.sh /app/start-dev.sh COPY --chown=node:node ./start-dev.sh /app/start-dev.sh
RUN chmod +x /app/start-dev.sh RUN chmod +x /app/start-dev.sh
USER node USER node
# ให้หา nodemon ได้จาก node_modules/.bin ที่ bake มาแล้ว # ให้หา nodemon ได้จาก node_modules/.bin ที่ bake มาแล้ว
# ENV NODE_ENV=development PATH="/opt/runtime/node_modules/.bin:${PATH}" # ENV NODE_ENV=development PATH="/opt/runtime/node_modules/.bin:${PATH}"
# ให้หา nodemon ได้ และระบุพอร์ตดีฟอลต์ # ให้หา nodemon ได้ และระบุพอร์ตดีฟอลต์
ENV NODE_ENV=development \ ENV NODE_ENV=development \
PORT=3001 \ PORT=3001 \
PATH="/opt/runtime/node_modules/.bin:${PATH}" PATH="/opt/runtime/node_modules/.bin:${PATH}"
EXPOSE 3001 9229 EXPOSE 3001 9229
HEALTHCHECK --interval=15s --timeout=5s --retries=10 \ HEALTHCHECK --interval=15s --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
# HEALTHCHECK --interval=15s --timeout=5s --retries=10 CMD curl -fsS http://127.0.0.1:7001/health || exit 1 # HEALTHCHECK --interval=15s --timeout=5s --retries=10 CMD curl -fsS http://127.0.0.1:7001/health || exit 1
CMD ["/app/start-dev.sh"] CMD ["/app/start-dev.sh"]
########## Runtime: Production ########## ########## Runtime: Production ##########
FROM base AS prod FROM base AS prod
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
# ใส่ deps สำหรับ prod # ใส่ deps สำหรับ prod
COPY --from=deps-prod /opt/runtime/node_modules /opt/runtime/node_modules COPY --from=deps-prod /opt/runtime/node_modules /opt/runtime/node_modules
# สร้าง symlink เช่นกัน เพื่อให้ Node resolve deps ได้จาก /app เหมือน dev # สร้าง symlink เช่นกัน เพื่อให้ Node resolve deps ได้จาก /app เหมือน dev
RUN ln -sfn /opt/runtime/node_modules /app/node_modules RUN ln -sfn /opt/runtime/node_modules /app/node_modules
# ใส่ซอร์ส (prod ไม่ bind โค้ด) # ใส่ซอร์ส (prod ไม่ bind โค้ด)
COPY . . COPY . .
USER node USER node
EXPOSE 3001 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"]

View File

@@ -0,0 +1,29 @@
# Backend build
## วิธีสร้าง package-lock.json ด้วย Docker
### 1. เช็ค uid:gid ของโฟลเดอร์โปรเจกต์บน QNAP
stat -c "%u:%g" .
### 2. ใช้ค่าที่ได้มาแทน UID:GID
```bash
docker run --rm \
-v "/share/Container/dms/frontend:/app" -w /app \
--user UID:GID -e HOME=/tmp \
node:20-alpine sh -lc 'mkdir -p /tmp && npm install --package-lock-only --ignore-scripts'
```
สร้าง package-lock.json โดย ไม่ติดตั้ง node_modules
--user $(id -u):$(id -g) ทำให้ไฟล์ที่ได้เป็นเจ้าของโดยยูสเซอร์ปัจจุบัน (กันปัญหา root-owned)
## ขั้นตอน Build บน QNAP
docker compose -f docker-backend-build.yml build --no-cache 2>&1 | tee backend_build.log
## สำหรับ build local
cd backend
docker build -t dms-backend:dev --target dev .

Binary file not shown.

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

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

View File

@@ -1,64 +1,64 @@
diff --git a/src/index.js b/src/index.js diff --git a/src/index.js b/src/index.js
--- a/src/index.js --- a/src/index.js
+++ b/src/index.js +++ b/src/index.js
@@ -1,9 +1,8 @@ @@ -1,9 +1,8 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import express from "express"; import express from "express";
-import cookieParser from "cookie-parser"; -import cookieParser from "cookie-parser";
import cors from "cors"; import cors from "cors";
import sql from "./db/index.js"; import sql from "./db/index.js";
import healthRouter from "./routes/health.js"; import healthRouter from "./routes/health.js";
import { authJwt } from "./middleware/authJwt.js"; import { authJwt } from "./middleware/authJwt.js";
@@ -64,7 +63,7 @@ @@ -64,7 +63,7 @@
// ✅ อยู่หลัง NPM/Reverse proxy → ให้ trust proxy เพื่อให้ cookie secure / proto ทำงานถูก // ✅ อยู่หลัง NPM/Reverse proxy → ให้ trust proxy เพื่อให้ cookie secure / proto ทำงานถูก
app.set("trust proxy", 1); app.set("trust proxy", 1);
-// CORS แบบกำหนด origin ตามรายการที่อนุญาต + อนุญาต credentials (จำเป็นสำหรับ cookie) -// CORS แบบกำหนด origin ตามรายการที่อนุญาต + อนุญาต credentials (จำเป็นสำหรับ cookie)
+// ✅ CORS สำหรับ Bearer token: ไม่ต้องใช้ credentials (ไม่มีคุกกี้) +// ✅ CORS สำหรับ Bearer token: ไม่ต้องใช้ credentials (ไม่มีคุกกี้)
app.use( app.use(
cors({ cors({
origin(origin, cb) { origin(origin, cb) {
if (!origin) return cb(null, true); // server-to-server / curl if (!origin) return cb(null, true); // server-to-server / curl
return cb(null, ALLOW_ORIGINS.includes(origin)); return cb(null, ALLOW_ORIGINS.includes(origin));
}, },
- credentials: true, - credentials: true,
+ credentials: false, + credentials: false,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
- allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"], - allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
+ allowedHeaders: [ + allowedHeaders: [
+ "Content-Type", + "Content-Type",
+ "Authorization", + "Authorization",
+ "X-Requested-With", + "X-Requested-With",
+ "Accept", + "Accept",
+ "Origin", + "Origin",
+ "Referer", + "Referer",
+ "User-Agent", + "User-Agent",
+ "Cache-Control", + "Cache-Control",
+ "Pragma" + "Pragma"
+ ], + ],
exposedHeaders: ["Content-Disposition", "Content-Length"], exposedHeaders: ["Content-Disposition", "Content-Length"],
}) })
); );
// preflight // preflight
app.options( app.options(
"*", "*",
cors({ 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)); return cb(null, ALLOW_ORIGINS.includes(origin));
}, },
- credentials: true, - credentials: true,
+ credentials: false, + credentials: false,
}) })
); );
-app.use(cookieParser()); -app.use(cookieParser());
+// ❌ ไม่ต้อง parse cookie แล้ว (เราไม่ใช้คุกกี้สำหรับ auth) +// ❌ ไม่ต้อง parse cookie แล้ว (เราไม่ใช้คุกกี้สำหรับ auth)
+// app.use(cookieParser()); +// app.use(cookieParser());
// Payload limits // Payload limits
app.use(express.json({ limit: "10mb" })); app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true, limit: "10mb" })); app.use(express.urlencoded({ extended: true, limit: "10mb" }));

View File

@@ -1,9 +1,12 @@
.git .git
node_modules .vscode
logs .backup
*.log node_modules
Dockerfile* logs
README*.md *.log
coverage Dockerfile*.*
tmp *.yml
README*.md
coverage
tmp
dist dist

15
backend/.eslintrc.json Normal file
View File

@@ -0,0 +1,15 @@
{
"env": {
"node": true,
"es2021": true,
"jest": true
},
"extends": ["eslint:recommended", "plugin:prettier/recommended"],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"prettier/prettier": "warn"
}
}

3
backend/.gitignore vendored Normal file
View File

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

7
backend/.prettierrc.json Normal file
View File

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

View File

@@ -1,69 +1,70 @@
# syntax=docker/dockerfile:1.6 # syntax=docker/dockerfile:1.6
########## Base (apk + common tools ติดตั้งตอน build) ########## ########## Base (apk + common tools ติดตั้งตอน build) ##########
FROM node:20-alpine AS base FROM node:20-alpine AS base
WORKDIR /app WORKDIR /app
RUN apk add --no-cache bash curl tzdata python3 make g++ \ RUN apk add --no-cache bash curl tzdata python3 make g++ \
&& ln -snf /usr/share/zoneinfo/Asia/Bangkok /etc/localtime \ && ln -snf /usr/share/zoneinfo/Asia/Bangkok /etc/localtime \
&& echo "Asia/Bangkok" > /etc/timezone && echo "Asia/Bangkok" > /etc/timezone
ENV TZ=Asia/Bangkok APP_HOME=/app RUNTIME_HOME=/opt/runtime ENV TZ=Asia/Bangkok APP_HOME=/app RUNTIME_HOME=/opt/runtime
########## Deps สำหรับ Production (no devDeps) ########## ########## Deps สำหรับ Production (no devDeps) ##########
FROM base AS deps-prod FROM base AS deps-prod
WORKDIR /work WORKDIR /work
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev || npm install --omit=dev RUN npm ci --omit=dev || npm install --omit=dev
RUN mkdir -p ${RUNTIME_HOME} && mv node_modules ${RUNTIME_HOME}/node_modules RUN mkdir -p ${RUNTIME_HOME} && mv node_modules ${RUNTIME_HOME}/node_modules
########## Deps สำหรับ Development (รวม devDeps) ########## ########## Deps สำหรับ Development (รวม devDeps) ##########
FROM base AS deps-dev FROM base AS deps-dev
RUN apk add --no-cache git openssh-client ca-certificates RUN apk add --no-cache git openssh-client ca-certificates
WORKDIR /work WORKDIR /work
COPY package*.json ./ COPY package*.json ./
RUN npm ci || npm install RUN npm ci || npm install
RUN mkdir -p ${RUNTIME_HOME} && mv node_modules ${RUNTIME_HOME}/node_modules RUN mkdir -p ${RUNTIME_HOME} && mv node_modules ${RUNTIME_HOME}/node_modules
########## Runtime: Development ########## ########## Runtime: Development ##########
FROM base AS dev FROM base AS dev
WORKDIR /app WORKDIR /app
# ทำงานเป็น root ชั่วคราวเพื่อจัดสิทธิ์/ลิงก์ แล้วค่อยเปลี่ยนเป็น node # ทำงานเป็น root ชั่วคราวเพื่อจัดสิทธิ์/ลิงก์ แล้วค่อยเปลี่ยนเป็น node
# 1) คัดลอก deps dev # 1) คัดลอก deps dev
COPY --from=deps-dev /opt/runtime/node_modules /opt/runtime/node_modules COPY --from=deps-dev /opt/runtime/node_modules /opt/runtime/node_modules
# 2) สร้าง symlink /app/node_modules → /opt/runtime/node_modules (กันปัญหา NODE_PATH/permission) # 2) สร้าง symlink /app/node_modules → /opt/runtime/node_modules (กันปัญหา NODE_PATH/permission)
RUN ln -sfn /opt/runtime/node_modules /app/node_modules \ RUN ln -sfn /opt/runtime/node_modules /app/node_modules \
&& chown -R node:node /app && chown -R node:node /app
# 3) ใส่สคริปต์ start-dev แล้วค่อยสลับ USER # 3) ใส่สคริปต์ start-dev แล้วค่อยสลับ USER
COPY --chown=node:node ./start-dev.sh /app/start-dev.sh COPY --chown=node:node ./start-dev.sh /app/start-dev.sh
RUN chmod +x /app/start-dev.sh RUN chmod +x /app/start-dev.sh
USER node USER node
# ให้หา nodemon ได้จาก node_modules/.bin ที่ bake มาแล้ว # ให้หา nodemon ได้จาก node_modules/.bin ที่ bake มาแล้ว
# ENV NODE_ENV=development PATH="/opt/runtime/node_modules/.bin:${PATH}" # ENV NODE_ENV=development PATH="/opt/runtime/node_modules/.bin:${PATH}"
# ให้หา nodemon ได้ และระบุพอร์ตดีฟอลต์ # ให้หา nodemon ได้ และระบุพอร์ตดีฟอลต์
ENV NODE_ENV=development \ ENV NODE_ENV=development \
PORT=3001 \ PORT=3001 \
PATH="/opt/runtime/node_modules/.bin:${PATH}" PATH="/opt/runtime/node_modules/.bin:${PATH}"
EXPOSE 3001 9229 EXPOSE 3001 9229
HEALTHCHECK --interval=15s --timeout=5s --retries=10 \ HEALTHCHECK --interval=15s --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
# HEALTHCHECK --interval=15s --timeout=5s --retries=10 CMD curl -fsS http://127.0.0.1:7001/health || exit 1 # HEALTHCHECK --interval=15s --timeout=5s --retries=10 CMD curl -fsS http://127.0.0.1:7001/health || exit 1
CMD ["/app/start-dev.sh"] CMD ["/app/start-dev.sh"]
########## Runtime: Production ########## ########## Runtime: Production ##########
FROM base AS prod FROM base AS prod
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
# ใส่ deps สำหรับ prod # ใส่ deps สำหรับ prod
COPY --from=deps-prod /opt/runtime/node_modules /opt/runtime/node_modules COPY --from=deps-prod /opt/runtime/node_modules /opt/runtime/node_modules
# สร้าง symlink เช่นกัน เพื่อให้ Node resolve deps ได้จาก /app เหมือน dev # สร้าง symlink เช่นกัน เพื่อให้ Node resolve deps ได้จาก /app เหมือน dev
RUN ln -sfn /opt/runtime/node_modules /app/node_modules RUN ln -sfn /opt/runtime/node_modules /app/node_modules
# ใส่ซอร์ส (prod ไม่ bind โค้ด) # ใส่ซอร์ส (prod ไม่ bind โค้ด)
COPY . . COPY . .
USER node USER node
EXPOSE 3001 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"]

77
backend/docker-compose.yml Executable file
View File

@@ -0,0 +1,77 @@
# File: backend/docker-compose.yml
# DMS Container v0_8_0 แยก service/ lcbp3-backend
x-restart: &restart_policy
restart: unless-stopped
x-logging: &default_logging
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
volumes:
backend_node_modules:
services:
backend:
<<: [*restart_policy, *default_logging]
image: dms-backend:dev
# pull_policy: never # <-- FINAL FIX ADDED HERE
container_name: dms_backend
stdin_open: true
tty: true
#user: "node"
user: "1000:1000"
working_dir: /app
deploy:
resources:
limits:
cpus: "2.0"
memory: 1G
reservations:
cpus: "0.25"
memory: 256M
environment:
TZ: "Asia/Bangkok"
CHOKIDAR_USEPOLLING: "1"
CHOKIDAR_INTERVAL: "300"
WATCHPACK_POLLING: "true"
# NODE_ENV: "production"
NODE_ENV: "development"
PORT: "3001"
DB_HOST: "mariadb"
DB_PORT: "3306"
DB_USER: "center"
DB_PASSWORD: "Center#2025"
DB_NAME: "dms"
JWT_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca"
JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca"
JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c"
ACCESS_TTL_MS: "900000"
REFRESH_TTL_MS: "604800000"
JWT_EXPIRES_IN: "12h"
PASSWORD_SALT_ROUNDS: "10"
FRONTEND_ORIGIN: "https://lcbp3.np-dms.work"
CORS_ORIGINS: "https://backend.np-dms.work,http://localhost:3000,http://127.0.0.1:3000,https://lcbp3.np-dms.work"
COOKIE_DOMAIN: ".np-dms.work"
RATE_LIMIT_WINDOW_MS: "900000"
RATE_LIMIT_MAX: "200"
BACKEND_LOG_DIR: "/app/logs"
networks:
lcbp3: {}
volumes:
- "/share/Container/dms/backend/src:/app/src:rw"
# - "/share/Container/dms/backend/package.json:/app/package.json"
# - "/share/Container/dms/backend/package-lock.json:/app/package-lock.json"
- "/share/dms-data:/share/dms-data:rw"
- "/share/Container/dms/logs/backend:/app/logs:rw"
# - "/share/Container/dms/backend/node_modules:/app/node_modules"
- "backend_node_modules:/app/node_modules"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3001/health"]
interval: 10s
timeout: 5s
retries: 30
networks:
lcbp3:
external: true

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +1,49 @@
{ {
"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",
"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)})\"", "lint:fix": "eslint . --ext .js --fix",
"postinstall": "node -e \"console.log('Installed dms-backend %s','0.6.0')\"" "test": "jest",
}, "test:watch": "jest --watch",
"dependencies": { "test:coverage": "jest --coverage",
"bcrypt": "5.1.1", "test:watch:coverage": "jest --watch --coverage",
"bcryptjs": "^2.4.3", "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)})\"",
"cookie-parser": "^1.4.7", "postinstall": "node -e \"console.log('Installed dms-backend %s','0.6.0')\""
"cors": "2.8.5", },
"dotenv": "16.4.5", "dependencies": {
"express": "^4.21.2", "bcrypt": "5.1.1",
"express-rate-limit": "7.4.0", "bcryptjs": "^2.4.3",
"helmet": "7.1.0", "cookie-parser": "^1.4.7",
"jsonwebtoken": "9.0.2", "cors": "2.8.5",
"mariadb": "3.3.1", "dotenv": "16.4.5",
"morgan": "^1.10.1", "express": "^4.21.2",
"multer": "^2.0.2", "express-rate-limit": "7.4.0",
"mysql2": "^3.11.0", "helmet": "7.1.0",
"sequelize": "6.37.3", "jsonwebtoken": "9.0.2",
"winston": "^3.13.0" "mariadb": "3.3.1",
}, "morgan": "^1.10.1",
"devDependencies": { "multer": "^2.0.2",
"nodemon": "^3.1.10" "mysql2": "^3.11.0",
} "sequelize": "6.37.3",
} "winston": "^3.13.0"
},
"devDependencies": {
"nodemon": "^3.1.10",
"eslint": "^8.56.0",
"prettier": "^3.1.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.7.0",
"supertest": "^6.3.4"
}
}

View File

@@ -1,25 +0,0 @@
{
"name": "dms-backend",
"version": "0.5.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node --env-file=../.env src/index.js",
"start": "node src/index.js",
"health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\""
},
"dependencies": {
"bcrypt": "5.1.1",
"cors": "2.8.5",
"dotenv": "16.4.5",
"express": "4.19.2",
"express-rate-limit": "7.4.0",
"helmet": "7.1.0",
"jsonwebtoken": "9.0.2",
"mariadb": "3.3.1",
"morgan": "1.10.0",
"sequelize": "6.37.3"
}
}

View File

@@ -1,38 +0,0 @@
{
"name": "dms-backend",
"version": "0.6.0",
"private": true,
"type": "module",
"main": "src/index.js",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"dev": "nodemon --watch src src/index.js",
"start": "node src/index.js",
"lint": "echo 'lint placeholder'",
"health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\"",
"postinstall": "node -e \"console.log('Installed dms-backend %s','0.6.0')\""
},
"dependencies": {
"bcrypt": "5.1.1",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "2.8.5",
"dotenv": "16.4.5",
"express": "^4.21.2",
"express-rate-limit": "7.4.0",
"helmet": "7.1.0",
"jsonwebtoken": "9.0.2",
"mariadb": "3.3.1",
"morgan": "^1.10.1",
"multer": "^2.0.2",
"mysql2": "^3.11.0",
"sequelize": "6.37.3",
"winston": "^3.13.0"
},
"devDependencies": {
"nodemon": "^3.1.10"
}
}

View File

@@ -1,60 +1,60 @@
// FILE: src/config/permissions.js // FILE: src/config/permissions.js
// Reference enum สำหรับฝั่งโค้ด/ฟรอนต์เท่านั้น // Reference enum สำหรับฝั่งโค้ด/ฟรอนต์เท่านั้น
// แหล่งจริงของ scope อ่านจาก DB: permissions.scope_level (ผ่าน requirePerm()) // แหล่งจริงของ scope อ่านจาก DB: permissions.scope_level (ผ่าน requirePerm())
const PERM = { const PERM = {
organizations: { organizations: {
view: "organizations.view", view: "organizations.view",
manage: "organizations.manage", manage: "organizations.manage",
}, },
projects: { projects: {
view: "projects.view", view: "projects.view",
manage: "projects.manage", manage: "projects.manage",
partiesManage: "project_parties.manage", partiesManage: "project_parties.manage",
}, },
drawings: { drawings: {
view: "drawings.view", view: "drawings.view",
upload: "drawings.upload", upload: "drawings.upload",
delete: "drawings.delete", delete: "drawings.delete",
}, },
documents: { documents: {
view: "documents.view", view: "documents.view",
manage: "documents.manage", manage: "documents.manage",
}, },
materials: { materials: {
view: "materials.view", view: "materials.view",
manage: "materials.manage", manage: "materials.manage",
}, },
ms: { ms: {
view: "ms.view", view: "ms.view",
manage: "ms.manage", manage: "ms.manage",
}, },
rfas: { rfas: {
view: "rfas.view", view: "rfas.view",
create: "rfas.create", create: "rfas.create",
respond: "rfas.respond", respond: "rfas.respond",
delete: "rfas.delete", delete: "rfas.delete",
}, },
correspondences: { correspondences: {
view: "corr.view", view: "corr.view",
manage: "corr.manage", manage: "corr.manage",
}, },
transmittals: { transmittals: {
manage: "transmittals.manage", manage: "transmittals.manage",
}, },
circulations: { circulations: {
manage: "cirs.manage", manage: "cirs.manage",
}, },
admin: { admin: {
access: "admin.access", access: "admin.access",
}, },
reports: { reports: {
view: "reports.view", view: "reports.view",
}, },
settings: { settings: {
manage: "settings.manage", manage: "settings.manage",
}, },
}; };
export { PERM }; export { PERM };
export default PERM; export default PERM;

View File

@@ -0,0 +1,39 @@
// FILE: backend/src/db/index.js (ESM)
import mysql from "mysql2/promise";
const {
DB_HOST = "mariadb",
DB_PORT = "3306",
DB_USER = "center",
DB_PASSWORD = "Center#2025",
DB_NAME = "dms",
DB_CONN_LIMIT = "10",
} = process.env;
const pool = mysql.createPool({
host: DB_HOST,
port: Number(DB_PORT),
user: DB_USER,
password: DB_PASSWORD,
database: DB_NAME,
connectionLimit: Number(DB_CONN_LIMIT),
waitForConnections: true,
namedPlaceholders: true,
dateStrings: true, // คงวันที่เป็น string
timezone: "Z", // ใช้ UTC
});
/**
* เรียก Stored Procedure แบบง่าย
* @param {string} procName ชื่อโปรซีเยอร์ เช่น "sp_rfa_create_with_items"
* @param {Array<any>} params ลำดับพารามิเตอร์
* @returns {Promise<any>} rows จาก CALL
*/
export async function callProc(procName, params = []) {
const placeholders = params.map(() => "?").join(",");
const sql = `CALL ${procName}(${placeholders})`;
const [rows] = await pool.query(sql, params);
return rows;
}
export default pool; // ใช้ sql.query(...) ได้ตามที่ routes เรียกอยู่

View File

@@ -1,39 +1,39 @@
// FILE: backend/src/db/index.js (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({
host: DB_HOST, host: DB_HOST,
port: Number(DB_PORT), port: Number(DB_PORT),
user: DB_USER, user: DB_USER,
password: DB_PASSWORD, password: DB_PASSWORD,
database: DB_NAME, database: DB_NAME,
connectionLimit: Number(DB_CONN_LIMIT), connectionLimit: Number(DB_CONN_LIMIT),
waitForConnections: true, waitForConnections: true,
namedPlaceholders: true, namedPlaceholders: true,
dateStrings: true, // คงวันที่เป็น string dateStrings: true, // คงวันที่เป็น string
timezone: "Z", // ใช้ UTC timezone: "Z", // ใช้ UTC
}); });
/** /**
* เรียก Stored Procedure แบบง่าย * เรียก Stored Procedure แบบง่าย
* @param {string} procName ชื่อโปรซีเยอร์ เช่น "sp_rfa_create_with_items" * @param {string} procName ชื่อโปรซีเยอร์ เช่น "sp_rfa_create_with_items"
* @param {Array<any>} params ลำดับพารามิเตอร์ * @param {Array<any>} params ลำดับพารามิเตอร์
* @returns {Promise<any>} rows จาก CALL * @returns {Promise<any>} rows จาก CALL
*/ */
export async function callProc(procName, params = []) { export async function callProc(procName, params = []) {
const placeholders = params.map(() => "?").join(","); const placeholders = params.map(() => "?").join(",");
const sql = `CALL ${procName}(${placeholders})`; const sql = `CALL ${procName}(${placeholders})`;
const [rows] = await pool.query(sql, params); const [rows] = await pool.query(sql, params);
return rows; return rows;
} }
export default pool; // ใช้ sql.query(...) ได้ตามที่ routes เรียกอยู่ export default pool; // ใช้ sql.query(...) ได้ตามที่ routes เรียกอยู่

View File

@@ -0,0 +1,71 @@
// FILE: backend/src/db/sequelize.js
// “lazy-load” ตาม env ปลอดภัยกว่า และยังคง dbReady() ให้เรียกทดสอบได้
// ใช้ได้เมื่อจำเป็น (เช่น งาน admin tool เฉพาะกิจ)
// ตั้ง ENABLE_SEQUELIZE=1 เพื่อเปิดใช้ Model loader; ไม่งั้นจะเป็นโหมดเบา ๆ
import { Sequelize } from "sequelize";
import { config } from "../config.js";
export const sequelize = new Sequelize(
config.DB.NAME,
config.DB.USER,
config.DB.PASS,
{
host: config.DB.HOST,
port: config.DB.PORT,
dialect: "mariadb",
logging: false,
dialectOptions: { timezone: "Z" },
define: { freezeTableName: true, underscored: false, timestamps: false },
pool: { max: 10, min: 0, idle: 10000 },
}
);
export let User = null;
export let Role = null;
export let Permission = null;
export let UserRole = null;
export let RolePermission = null;
if (process.env.ENABLE_SEQUELIZE === "1") {
// โหลดโมเดลแบบ on-demand เพื่อลดความเสี่ยง runtime หากไฟล์โมเดลไม่มี
const mdlUser = await import("./models/User.js").catch(() => null);
const mdlRole = await import("./models/Role.js").catch(() => null);
const mdlPerm = await import("./models/Permission.js").catch(() => null);
const mdlUR = await import("./models/UserRole.js").catch(() => null);
const mdlRP = await import("./models/RolePermission.js").catch(() => null);
if (mdlUser?.default) User = mdlUser.default(sequelize);
if (mdlRole?.default) Role = mdlRole.default(sequelize);
if (mdlPerm?.default) Permission = mdlPerm.default(sequelize);
if (mdlUR?.default) UserRole = mdlUR.default(sequelize);
if (mdlRP?.default) RolePermission = mdlRP.default(sequelize);
if (User && Role && Permission && UserRole && RolePermission) {
User.belongsToMany(Role, {
through: UserRole,
foreignKey: "user_id",
otherKey: "role_id",
});
Role.belongsToMany(User, {
through: UserRole,
foreignKey: "role_id",
otherKey: "user_id",
});
Role.belongsToMany(Permission, {
through: RolePermission,
foreignKey: "role_id",
otherKey: "permission_id",
});
Permission.belongsToMany(Role, {
through: RolePermission,
foreignKey: "permission_id",
otherKey: "role_id",
});
}
}
export async function dbReady() {
// โหมดเบา ๆ: แค่ทดสอบเชื่อมต่อ
await sequelize.authenticate();
}

0
backend/src/db/sequelize.js Normal file → Executable file
View File

View File

@@ -1,4 +1,5 @@
// FILE: backend/src/index.js (ESM) ไฟล์ฉบับ “Bearer-only” // FILE: backend/src/index.js (ESM) ไฟล์ฉบับ “Bearer-only”
// FILE: src/index.js (ESM)
import fs from "node:fs"; import fs from "node:fs";
import express from "express"; import express from "express";
import cors from "cors"; import cors from "cors";
@@ -36,9 +37,7 @@ const ALLOW_ORIGINS = [
"http://127.0.0.1:3000", "http://127.0.0.1:3000",
FRONTEND_ORIGIN, FRONTEND_ORIGIN,
...(process.env.CORS_ALLOWLIST ...(process.env.CORS_ALLOWLIST
? process.env.CORS_ALLOWLIST.split(",") ? process.env.CORS_ALLOWLIST.split(",").map((x) => x.trim()).filter(Boolean)
.map((x) => x.trim())
.filter(Boolean)
: []), : []),
].filter(Boolean); ].filter(Boolean);
@@ -106,12 +105,8 @@ app.get("/health", async (_req, res) => {
}); });
app.get("/livez", (_req, res) => res.send("ok")); app.get("/livez", (_req, res) => res.send("ok"));
app.get("/readyz", async (_req, res) => { app.get("/readyz", async (_req, res) => {
try { try { await sql.query("SELECT 1"); res.send("ready"); }
await sql.query("SELECT 1"); catch { res.status(500).send("not-ready"); }
res.send("ready");
} catch {
res.status(500).send("not-ready");
}
}); });
app.get("/info", (_req, res) => app.get("/info", (_req, res) =>
res.json({ res.json({
@@ -164,9 +159,7 @@ 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 { try { await sql.end(); } catch {}
await sql.end();
} catch {}
console.log("[SHUTDOWN] complete"); console.log("[SHUTDOWN] complete");
process.exit(0); process.exit(0);
} catch (e) { } catch (e) {

View File

@@ -3,118 +3,41 @@
// - Project-scoped access control base on user_project_roles + permissions // - 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) // - 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 // - Uses UserProjectRole model to check project membership
// Helper ABAC เสริมบางเคส (ถ้าต้องการฟิลเตอร์/บังคับ project_id ตรง ๆ)
// หมายเหตุ: โดยหลักแล้วคุณควรใช้ requirePerm() ที่บังคับ ABAC อัตโนมัติจาก permissions.scope_level
import { sequelize } from "../db/sequelize.js"; export function projectScopedViewFallback(moduleName) {
import UPRModel from "../db/models/UserProjectRole.js"; // ใช้ในเคส legacy เท่านั้น
/**
* ดึง project_id ที่ผู้ใช้เข้าถึงได้ (จาก user_project_roles)
*/
export async function getUserProjectIds(user_id) {
const UPR = UPRModel(sequelize);
const rows = await UPR.findAll({ where: { user_id } });
return [...new Set(rows.map((r) => r.project_id))];
}
/**
* projectScopedView(moduleName) -> middleware
* - ต้องมี permission '<module>:view' หรือ
* - เป็นสมาชิกของโปรเจ็กต์ (ผ่าน user_project_roles)
* Behavior:
* - ถ้า query ไม่มี project_id และผู้ใช้ไม่ใช่ Admin:
* จำกัดผลลัพธ์ให้เฉพาะโปรเจ็กต์ที่ผู้ใช้เป็นสมาชิก
* - ถ้ามี project_id: บังคับตรวจสิทธิ์การเป็นสมาชิกของโปรเจ็กต์นั้น (เว้นแต่เป็น Admin)
*/
export function projectScopedView(moduleName) {
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 const qProjectId = req.query?.project_id ? Number(req.query.project_id) : null;
? 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) if (!p.project_ids?.length) return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
return res
.status(403)
.json({ error: "Forbidden: no accessible projects" });
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 const qProjectId = req.query?.project_id ? Number(req.query.project_id) : null;
? Number(req.query.project_id) if (!qProjectId) return res.status(400).json({ error: "project_id query required" });
: null;
if (!qProjectId)
return res.status(400).json({ error: "project_id query required" });
next(); next();
}; };
} }

View File

@@ -0,0 +1,61 @@
// FILE: backend/src/middleware/auth.js
import jwt from "jsonwebtoken";
import { config } from "../config.js";
import { User, Role, UserRole } from "../db/sequelize.js";
export function signAccessToken(payload) {
return jwt.sign(payload, config.JWT.SECRET, {
expiresIn: config.JWT.EXPIRES_IN,
});
}
export function signRefreshToken(payload) {
return jwt.sign(payload, config.JWT.REFRESH_SECRET, {
expiresIn: config.JWT.REFRESH_EXPIRES_IN,
});
}
export function extractToken(req) {
// ให้คุกกี้มาก่อน แล้วค่อย Bearer (รองรับทั้งสองทาง)
const cookieTok = req.cookies?.access_token || null;
if (cookieTok) return cookieTok;
const hdr = req.headers.authorization || "";
return hdr.startsWith("Bearer ") ? hdr.slice(7) : null;
}
export function requireAuth(req, res, next) {
if (req.path === "/health") return next(); // อนุญาต health เสมอ
const token = extractToken(req);
if (!token) return res.status(401).json({ error: "Missing token" });
try {
req.user = jwt.verify(token, config.JWT.SECRET);
next();
} catch {
return res.status(401).json({ error: "Invalid/Expired token" });
}
}
// ใช้กับเส้นทางที่ login แล้วจะ enrich ต่อได้ แต่ไม่บังคับ
export function optionalAuth(req, _res, next) {
const token = extractToken(req);
if (!token) return next();
try {
req.user = jwt.verify(token, config.JWT.SECRET);
} catch {}
next();
}
export async function enrichRoles(req, _res, next) {
if (!req.user?.user_id) return next();
const rows = await UserRole.findAll({
where: { user_id: req.user.user_id },
include: [{ model: Role }],
}).catch(() => []);
req.user.roles = rows.map((r) => r.role?.role_name).filter(Boolean);
next();
}
export function hasPerm(req, perm) {
const set = new Set(req?.user?.permissions || []);
return set.has(perm);
}

54
backend/src/middleware/auth.js Normal file → Executable file
View File

@@ -1,50 +1,30 @@
// FILE: src/middleware/auth.js // FILE: backend/src/middleware/auth.js
// Authentication & Authorization middleware // (ถ้ายังใช้อยู่) ปรับให้สอดคล้อง Bearer + principal
// - JWT-based authentication
// - Role & Permission enrichment
// - RBAC (Role-Based Access Control) helpers
// - Requires User, Role, Permission, UserRole, RolePermission models
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { config } from "../config.js";
import { User, Role, UserRole } from "../db/sequelize.js";
export function signAccessToken(payload) { export function signAccessToken(payload) {
return jwt.sign(payload, config.JWT.SECRET, { const { JWT_SECRET = "dev-secret", JWT_EXPIRES_IN = "30m" } = process.env;
expiresIn: config.JWT.EXPIRES_IN, 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, { const { JWT_REFRESH_SECRET = "dev-refresh", JWT_REFRESH_EXPIRES_IN = "30d" } = process.env;
expiresIn: config.JWT.REFRESH_EXPIRES_IN, 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);
}

View File

@@ -1,33 +1,37 @@
// FILE: src/middleware/authJwt.js // FILE: src/middleware/authJwt.js
// 03.2 4) เพิ่ม middleware authJwt (ใหม่) // 03.2 4) เพิ่ม middleware authJwt (ใหม่)
// นำ middleware นี้ไปใส่ หลัง verify JWT เสมอ เช่น app.use('/api', authJwt(), routes) // นำ middleware นี้ไปใส่ หลัง verify JWT เสมอ เช่น app.use('/api', authJwt(), routes)
// Simple JWT authentication middleware example // Simple JWT authentication middleware example
// - For demonstration or simple use cases // - For demonstration or simple use cases
// - Not as feature-rich as auth.js (no role/permission enrichment) // - Not as feature-rich as auth.js (no role/permission enrichment)
// - Can be used standalone or alongside auth.js // - Can be used standalone or alongside auth.js
// authJwt.js สมมติคุณมี JWT อยู่แล้ว (ปรับ verify ตามที่ใช้จริง) // authJwt.js สมมติคุณมี JWT อยู่แล้ว (ปรับ verify ตามที่ใช้จริง)
// - ตรวจ token และเติม req.user // - ตรวจ token และเติม req.user
// - ไม่ได้เติม roles/permissions (ถ้าต้องการให้ใช้ auth.js แทนหรือร่วมกัน) // - ไม่ได้เติม roles/permissions (ถ้าต้องการให้ใช้ auth.js แทนหรือร่วมกัน)
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
export function authJwt() { export function authJwt() {
const { JWT_SECRET = "dev-secret" } = process.env; 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;
const m = /^Bearer\s+(.+)$/i.exec(h || ""); const m = /^Bearer\s+(.+)$/i.exec(h || "");
//if (!token) return res.status(401).json({ error: "Unauthenticated" }); //if (!token) return res.status(401).json({ error: "Unauthenticated" });
if (!m) 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);
const payload = jwt.verify(m[1], JWT_SECRET, { issuer: "dms-backend" }); const payload = jwt.verify(m[1], JWT_SECRET, { issuer: "dms-backend" });
// แนบข้อมูลขั้นต่ำให้ middleware ถัดไป // แนบข้อมูลขั้นต่ำให้ middleware ถัดไป
req.auth = { user_id: payload.user_id, username: payload.username }; req.auth = { user_id: payload.user_id, username: payload.username };
//req.user = { user_id: payload.user_id, username: payload.username }; //req.user = { user_id: payload.user_id, username: payload.username };
next(); // เผื่อโค้ดเก่าอ้างอิง req.user
} catch (e) { req.user = req.user || {};
return res.status(401).json({ error: "Unauthenticated" }); req.user.user_id = payload.user_id;
} req.user.username = payload.username;
}; next();
} } catch (e) {
return res.status(401).json({ error: "Unauthenticated" });
}
};
}

39
backend/src/middleware/index.js Executable file
View File

@@ -0,0 +1,39 @@
// File: backend/src/middleware/index.js
import * as authJwt from "./authJwt.js";
import * as abac from "./abac.js";
import * as auth from "./auth.js";
import * as errorHandler from "./errorHandler.js";
import * as loadPrincipal from "./loadPrincipal.js";
import * as permGuard from "./permGuard.js";
import * as permissions from "./permissions.js";
import * as rbac from "./rbac.js";
import * as requirePerm from "./requirePerm.js";
// Export ทุกอย่างออกมาเป็น named exports
// เพื่อให้สามารถ import แบบ `import { authJwt, permGuard } from '../middleware';` ได้
export {
authJwt,
abac,
auth,
errorHandler,
loadPrincipal,
permGuard,
permissions,
rbac,
requirePerm,
};
// (Optional) สร้าง default export สำหรับกรณีที่ต้องการ import ทั้งหมดใน object เดียว
const middleware = {
authJwt,
abac,
auth,
errorHandler,
loadPrincipal,
permGuard,
permissions,
rbac,
requirePerm,
};
export default middleware;

121
backend/src/middleware/loadPrincipal.js Normal file → Executable file
View File

@@ -1,23 +1,98 @@
// FILE: src/middleware/loadPrincipal.js // FILE: src/middleware/loadPrincipal.js
// 03.2 4) เพิ่ม middleware loadPrincipal (ใหม่) // 03.2 4) เพิ่ม middleware loadPrincipal (ใหม่)
// นำ middleware นี้ไปใส่ หลัง verify JWT เสมอ เช่น app.use('/api', authJwt(), loadPrincipalMw(), routes) // นำ middleware นี้ไปใส่ หลัง verify JWT เสมอ เช่น app.use('/api', authJwt(), loadPrincipalMw(), routes)
// Load principal (roles + permissions) middleware // Load principal (roles + permissions) middleware
// - Uses rbac.js utility to load principal info // - Uses rbac.js utility to load principal info
// - Attaches to req.principal // - Attaches to req.principal
// - Requires req.user.user_id to be populated (e.g. via auth.js or authJwt.js) // - Requires req.user.user_id to be populated (e.g. via auth.js or authJwt.js)
// โหลด principal จาก DB แล้วแนบไว้ใน req.principal
import { loadPrincipal } from "../utils/rbac.js"; // NOTE: ตรงนี้สมมุติว่าคุณมี service/query ฝั่ง DB อยู่แล้ว (เช่น sql/Sequelize)
// ถ้าคุณมีฟังก์ชันโหลด principal อยู่ที่อื่น ให้แทน logic DB ตรง FIXME ด้านล่าง
export function loadPrincipalMw() { // ใช้ req.auth.user_id และตั้ง req.principal ให้ครบ (RBAC + ABAC)
return async (req, res, next) => {
try { import sql from "../db/index.js";
if (!req.user?.user_id)
return res.status(401).json({ error: "Unauthenticated" }); export function loadPrincipalMw() {
req.principal = await loadPrincipal(req.user.user_id); return async (req, res, next) => {
next(); try {
} catch (err) { const uid = req?.auth?.user_id || req?.user?.user_id;
console.error("loadPrincipal error", err); if (!uid) return res.status(401).json({ error: "Unauthenticated" });
res.status(500).json({ error: "Failed to load principal" });
} // --- 1) users (รวม org_id)
}; const [[u]] = await sql.query(
} `SELECT user_id, username, email, first_name, last_name, org_id, is_active
FROM users WHERE user_id=? LIMIT 1`,
[uid]
);
if (!u || u.is_active === 0) return res.status(401).json({ error: "Unauthenticated" });
// --- 2) roles (global)
const [roleRows] = await sql.query(
`SELECT r.role_id, r.role_code, r.role_name
FROM user_roles ur
JOIN roles r ON r.role_id = ur.role_id
WHERE ur.user_id=?`,
[uid]
);
const roleCodes = new Set(roleRows.map(r => r.role_code));
const is_superadmin = roleCodes.has("SUPER_ADMIN");
// --- 3) permissions (ผ่าน role_permissions)
const [permRows] = await sql.query(
`SELECT DISTINCT p.perm_code
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.permission_id = rp.permission_id AND p.is_active=1
WHERE ur.user_id=?`,
[uid]
);
const permSet = new Set(permRows.map(x => x.perm_code));
// --- 4) project scope (user_project_roles)
const [projRows] = await sql.query(
`SELECT DISTINCT project_id FROM user_project_roles WHERE user_id=?`,
[uid]
);
const project_ids = projRows.map(r => r.project_id);
// --- 5) org scope: users.org_id + orgs จาก project_parties ของโปรเจ็คที่เข้าถึง
const baseOrgIds = u.org_id ? [u.org_id] : [];
let projOrgIds = [];
if (project_ids.length) {
const [rows] = await sql.query(
`SELECT DISTINCT org_id FROM project_parties WHERE project_id IN (?)`,
[project_ids]
);
projOrgIds = rows.map(r => r.org_id);
}
const org_ids = Array.from(new Set([...baseOrgIds, ...projOrgIds]));
req.principal = {
user_id: u.user_id,
username: u.username,
email: u.email,
first_name: u.first_name,
last_name: u.last_name,
org_id: u.org_id || null,
roles: roleRows.map(r => ({ role_id: r.role_id, role_code: r.role_code, role_name: r.role_name })),
permissions: permSet, // Set ของ perm_code
project_ids,
org_ids,
is_superadmin,
// helpers
can: (code) => is_superadmin || permSet.has(code),
canAny: (codes=[]) => is_superadmin || codes.some(c => permSet.has(c)),
canAll: (codes=[]) => is_superadmin || codes.every(c => permSet.has(c)),
inProject: (pid) => is_superadmin || project_ids.includes(Number(pid)),
inOrg: (oid) => is_superadmin || org_ids.includes(Number(oid)),
};
next();
} catch (err) {
console.error("loadPrincipal error", err);
res.status(500).json({ error: "Failed to load principal" });
}
};
}

View File

@@ -2,16 +2,14 @@
// Permission guard middleware // Permission guard middleware
// - Checks if user has required permissions // - Checks if user has required permissions
// - Requires req.user.permissions to be populated (e.g. via auth.js or authJwt.js with enrichment) // - Requires req.user.permissions to be populated (e.g. via auth.js or authJwt.js with enrichment)
// เปลี่ยนให้เป็น wrapper ที่เรียก req.principal (ทางเก่ายังใช้ได้)**
/**
* requirePerm('rfa:create') => ตรวจว่ามี permission นี้ใน req.user.permissions
* ต้องแน่ใจว่าเรียก enrichPermissions() มาก่อน หรือคำนวณที่จุดเข้าใช้งาน
*/
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();
}; };
} }

View File

@@ -2,39 +2,40 @@
// Permission calculation and enrichment middleware // Permission calculation and enrichment middleware
// - Computes effective permissions for a user based on their roles // - Computes effective permissions for a user based on their roles
// - Attaches permissions to req.user.permissions // - Attaches permissions to req.user.permissions
// ใช้เฉพาะกรณีที่คุณยังมี stack Sequelize เดิมอยู่ และอยาก enrich จาก Role/Permission model
// โดยทั่วไป ถ้าคุณใช้ loadPrincipalMw() อยู่แล้ว สามารถไม่ใช้ไฟล์นี้ได้
import { Role, Permission, UserRole, RolePermission } from "../db/sequelize.js"; 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();
}; };
} }

View File

@@ -5,18 +5,19 @@
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();
}; };
} }

View File

@@ -0,0 +1,18 @@
// FILE: src/middleware/requireBearer.js
import jwt from "jsonwebtoken";
import { findUserById } from "../db/models/users.js";
export async function requireBearer(req, res, next) {
const hdr = req.get("Authorization") || "";
const m = hdr.match(/^Bearer\s+(.+)$/i);
if (!m) return res.status(401).json({ error: "Unauthenticated" });
try {
const payload = jwt.verify(m[1], process.env.JWT_ACCESS_SECRET, { issuer: "dms-backend" });
const user = await findUserById(payload.user_id);
if (!user) return res.status(401).json({ error: "Unauthenticated" });
req.user = { user_id: user.user_id, username: user.username, email: user.email, first_name: user.first_name, last_name: user.last_name };
next();
} catch {
return res.status(401).json({ error: "Unauthenticated" });
}
}

View File

@@ -1,37 +1,64 @@
// FILE: src/middleware/requirePerm.js // FILE: src/middleware/requirePerm.js
// 03.2 4) เพิ่ม middleware requirePerm (ใหม่) // 03.2 4) เพิ่ม middleware requirePerm (ใหม่)
// นำ middleware นี้ไปใส่ หลัง loadPrincipal เสมอ เช่น app.use('/api', authJwt(), loadPrincipalMw(), requirePerm('correspondence.create', {scope:'org', getOrgId: req=>...}), routes) // นำ 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)=>{...}) // หรือใส่ใน route เดี่ยวๆ ก็ได้ เช่น router.post('/', requirePerm('correspondence.create', {scope:'org', getOrgId: req=>...}), (req,res)=>{...})
// Permission requirement middleware with scope support // Permission requirement middleware with scope support
// - Uses canPerform() utility from rbac.js // - Uses canPerform() utility from rbac.js
// - Supports global, org, and project scopes // - Supports global, org, and project scopes
// - Requires req.principal to be populated (e.g. via loadPrincipal middleware) // - Requires req.principal to be populated (e.g. via loadPrincipal middleware)
// เช็คตาม perm_code + ABAC อัตโนมัติจาก permissions.scope_level
import { canPerform } from "../utils/rbac.js"; import sql from "../db/index.js";
/** let _permMap = null;
* requirePerm('correspondence.create', { scope: 'org', getOrgId: req => ... }) let _loadedAt = 0;
* scope: 'global' | 'org' | 'project' const TTL_MS = 60_000;
*/
export function requirePerm( async function getPermRegistry() {
permCode, const now = Date.now();
{ scope = "global", getOrgId = null, getProjectId = null } = {} if (_permMap && now - _loadedAt < TTL_MS) return _permMap;
) { const [rows] = await sql.query(
return async (req, res, next) => { `SELECT perm_code, scope_level FROM permissions WHERE is_active=1`
try { );
const orgId = getOrgId ? await getOrgId(req) : null; _permMap = new Map(rows.map(r => [r.perm_code, r.scope_level])); // GLOBAL | ORG | PROJECT
const projectId = getProjectId ? await getProjectId(req) : null; _loadedAt = now;
return _permMap;
if (canPerform(req.principal, permCode, { scope, orgId, projectId })) }
return next();
/**
return res.status(403).json({ * requirePerm('rfas.view', { projectParam: 'project_id', orgParam: 'org_id' })
error: "FORBIDDEN", * - GLOBAL: แค่มี perm ก็ผ่าน
message: `Require ${permCode} (${scope}-scoped)`, * - ORG: ต้องมี perm + อยู่ใน org scope (อ่าน org_id จาก param หากระบุ; ไม่ระบุจะใช้ req.principal.org_id)
}); * - PROJECT:ต้องมี perm + อยู่ใน project scope (อ่าน project_id จาก param)
} catch (e) { */
console.error("requirePerm error", e); export function requirePerm(permCode, { projectParam, orgParam } = {}) {
res.status(500).json({ error: "Permission check error" }); return async (req, res, next) => {
} const p = req.principal;
}; if (!p) return res.status(401).json({ error: "Unauthenticated" });
}
if (!(p.is_superadmin || p.permissions?.has?.(permCode))) {
return res.status(403).json({ error: "FORBIDDEN", need: permCode });
}
const registry = await getPermRegistry();
const scope = registry.get(permCode) || "GLOBAL";
const readParam = (name) => req.params?.[name] ?? req.query?.[name] ?? req.body?.[name];
if (scope === "PROJECT") {
const pid = Number(projectParam ? readParam(projectParam) : undefined);
if (!p.is_superadmin) {
if (!pid || !p.inProject(pid)) {
return res.status(403).json({ error: "FORBIDDEN_PROJECT", project_id: pid || null });
}
}
} else if (scope === "ORG") {
const oid = Number(orgParam ? readParam(orgParam) : p.org_id);
if (!p.is_superadmin) {
if (!oid || !p.inOrg(oid)) {
return res.status(403).json({ error: "FORBIDDEN_ORG", org_id: oid || null });
}
}
}
next();
};
}

View File

@@ -1,94 +1,94 @@
// FILE: src/routes/admin.js // FILE: src/routes/admin.js
import { Router } from "express"; import { Router } from "express";
import os from "node:os"; import os from "node:os";
import sql from "../db/index.js"; import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js"; import { requirePerm } from "../middleware/requirePerm.js";
const r = Router(); const r = Router();
/** /**
* GET /api/admin/sysinfo * GET /api/admin/sysinfo
* perm: admin.access (ORG scope) ใช้สิทธิ์กลุ่ม admin * perm: admin.access (ORG scope) ใช้สิทธิ์กลุ่ม admin
*/ */
r.get( r.get(
"/sysinfo", "/sysinfo",
requirePerm("admin.access", { orgParam: "org_id" }), requirePerm("admin.access", { orgParam: "org_id" }),
async (_req, res) => { async (_req, res) => {
try { try {
await sql.query("SELECT 1"); await sql.query("SELECT 1");
res.json({ res.json({
now: new Date().toISOString(), now: new Date().toISOString(),
node: process.version, node: process.version,
platform: os.platform(), platform: os.platform(),
arch: os.arch(), arch: os.arch(),
cpus: os.cpus()?.length, cpus: os.cpus()?.length,
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: { env: {
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
APP_VERSION: process.env.APP_VERSION, 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 * POST /api/admin/maintenance/reindex
* perm: settings.manage (GLOBAL) งานดูแลระบบ * perm: settings.manage (GLOBAL) งานดูแลระบบ
*/ */
r.post( r.post(
"/maintenance/reindex", "/maintenance/reindex",
requirePerm("settings.manage"), requirePerm("settings.manage"),
async (_req, res) => { async (_req, res) => {
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 * GET /api/admin/perm-matrix?format=json
* perm: admin.access (ORG) * perm: admin.access (ORG)
*/ */
r.get( r.get(
"/perm-matrix", "/perm-matrix",
requirePerm("admin.access", { orgParam: "org_id" }), requirePerm("admin.access", { orgParam: "org_id" }),
async (req, res) => { async (req, res) => {
const format = String(req.query.format || "json").toLowerCase(); const format = String(req.query.format || "json").toLowerCase();
const [roles] = await sql.query( const [roles] = await sql.query(
`SELECT r.role_id, r.role_code, r.role_name, `SELECT r.role_id, r.role_code, r.role_name,
GROUP_CONCAT(p.perm_code ORDER BY p.perm_code SEPARATOR ', ') AS perm_codes GROUP_CONCAT(p.perm_code ORDER BY p.perm_code SEPARATOR ', ') AS perm_codes
FROM roles r FROM roles r
LEFT JOIN role_permissions rp ON rp.role_id = r.role_id LEFT JOIN role_permissions rp ON rp.role_id = r.role_id
LEFT JOIN permissions p ON p.permission_id = rp.permission_id LEFT JOIN permissions p ON p.permission_id = rp.permission_id
GROUP BY r.role_id, r.role_code, r.role_name GROUP BY r.role_id, r.role_code, r.role_name
ORDER BY r.role_code` ORDER BY r.role_code`
); );
if (format === "json") return res.json({ roles }); if (format === "json") return res.json({ roles });
// markdown แบบง่าย // markdown แบบง่าย
const lines = [ const lines = [
`# Permission Matrix`, `# Permission Matrix`,
`_Generated at: ${new Date().toISOString()}_`, `_Generated at: ${new Date().toISOString()}_`,
`| # | Role Code | Role Name | Permissions |`, `| # | Role Code | Role Name | Permissions |`,
`|---:|:---------|:----------|:------------|`, `|---:|:---------|:----------|:------------|`,
...roles.map( ...roles.map(
(r, i) => (r, i) =>
`| ${i + 1} | \`${r.role_code}\` | ${r.role_name || ""} | ${ `| ${i + 1} | \`${r.role_code}\` | ${r.role_name || ""} | ${
r.perm_codes || "" r.perm_codes || ""
} |` } |`
), ),
]; ];
res.setHeader("Content-Type", "text/markdown; charset=utf-8"); res.setHeader("Content-Type", "text/markdown; charset=utf-8");
res.send(lines.join("\n")); res.send(lines.join("\n"));
} }
); );
export default r; export default r;

View File

@@ -0,0 +1,137 @@
// backend/src/routes/auth.js
import { Router } from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { config } from "../config.js";
import { User } from "../db/sequelize.js";
import {
signAccessToken,
signRefreshToken,
requireAuth,
} from "../middleware/auth.js";
const r = Router();
// cookie options — ทำในไฟล์นี้เลย (ไม่เพิ่ม utils ใหม่)
function cookieOpts(maxAgeMs) {
const isProd = process.env.NODE_ENV === "production";
const opts = {
httpOnly: true,
secure: true, // หลัง Nginx/HTTPS
sameSite: "none", // ส่งข้าม subdomain ได้
path: "/",
maxAge: maxAgeMs,
};
if (config.COOKIE_DOMAIN) opts.domain = config.COOKIE_DOMAIN; // เช่น .np-dms.work
if (!isProd && process.env.ALLOW_INSECURE_COOKIE === "1") {
opts.secure = false;
opts.sameSite = "lax";
}
return opts;
}
// helper TTL จาก config เดิม
const ACCESS_TTL_MS = (() => {
// รับทั้งรูปแบบ "15m" (เช่น EXPIRES_IN) หรือ milliseconds
// ถ้าเป็นเลขอยู่แล้ว (ms) ก็ใช้เลย
if (/^\d+$/.test(String(config.JWT.EXPIRES_IN)))
return Number(config.JWT.EXPIRES_IN);
// แปลงรูปแบบเช่น "15m" เป็น ms แบบง่าย ๆ
const s = String(config.JWT.EXPIRES_IN || "15m");
const n = parseInt(s, 10);
if (s.endsWith("h")) return n * 60 * 60 * 1000;
if (s.endsWith("m")) return n * 60 * 1000;
if (s.endsWith("s")) return n * 1000;
return 15 * 60 * 1000;
})();
const REFRESH_TTL_MS = (() => {
if (/^\d+$/.test(String(config.JWT.REFRESH_EXPIRES_IN)))
return Number(config.JWT.REFRESH_EXPIRES_IN);
const s = String(config.JWT.REFRESH_EXPIRES_IN || "7d");
const n = parseInt(s, 10);
if (s.endsWith("d")) return n * 24 * 60 * 60 * 1000;
if (s.endsWith("h")) return n * 60 * 60 * 1000;
if (s.endsWith("m")) return n * 60 * 1000;
if (s.endsWith("s")) return n * 1000;
return 7 * 24 * 60 * 60 * 1000;
})();
// == POST /api/auth/login ==
r.post("/login", async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password)
return res.status(400).json({ error: "USERNAME_PASSWORD_REQUIRED" });
const user = await User.findOne({ where: { username }, raw: true });
if (!user) return res.status(401).json({ error: "INVALID_CREDENTIALS" });
const ok = await bcrypt.compare(password, user.password_hash || "");
if (!ok) return res.status(401).json({ error: "INVALID_CREDENTIALS" });
// NOTE: สิทธิ์จริง ๆ ให้ดึงจากตาราง role/permission ของคุณ
const permissions = []; // ใส่เปล่าไว้ก่อน (คุณมี enrichRoles ที่อื่นอยู่แล้ว)
const payload = {
user_id: user.user_id,
username: user.username,
permissions,
};
const access = signAccessToken(payload);
const refresh = signRefreshToken({ user_id: user.user_id });
// ตั้งคุกกี้ (และยังส่ง token ใน body ได้ถ้าคุณใช้อยู่)
res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS));
res.cookie("refresh_token", refresh, cookieOpts(REFRESH_TTL_MS));
return res.json({
ok: true,
token: access,
refresh_token: refresh,
user: { user_id: user.user_id, username: user.username, email: user.email },
});
});
// == GET /api/auth/me ==
r.get("/me", requireAuth, async (req, res) => {
// enrich เพิ่มจากฐานได้ตามต้องการ; ตอนนี้เอาเบื้องต้นจาก token
return res.json({
ok: true,
user: {
user_id: req.user.user_id,
username: req.user.username,
permissions: req.user.permissions || [],
},
});
});
// == POST /api/auth/refresh ==
r.post("/refresh", async (req, res) => {
// รับจากคุกกี้ก่อน แล้วค่อย Authorization
const bearer = req.headers.authorization?.startsWith("Bearer ")
? req.headers.authorization.slice(7)
: null;
const rt = req.cookies?.refresh_token || bearer;
if (!rt) return res.status(401).json({ error: "Unauthenticated" });
try {
// verify refresh โดยตรงด้วย config.JWT.REFRESH_SECRET (คงสไตล์เดิม)
const p = jwt.verify(rt, config.JWT.REFRESH_SECRET, { clockTolerance: 10 });
// โหลดสิทธิ์ล่าสุดจากฐานได้ ถ้าต้องการ; ตอนนี้ใส่ [] ไว้ก่อน
const permissions = [];
const access = signAccessToken({ user_id: p.user_id, permissions });
res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS));
return res.json({ ok: true, token: access });
} catch {
return res.status(401).json({ error: "Unauthenticated" });
}
});
// == POST /api/auth/logout ==
r.post("/logout", (_req, res) => {
res.clearCookie("access_token", { path: "/" });
res.clearCookie("refresh_token", { path: "/" });
return res.json({ ok: true });
});
export default r;

View File

@@ -1,18 +1,10 @@
// FILE: src/routes/auth.js (ESM) — Bearer only, refresh via header/body, forgot/reset password // FILE: backend/src/routes/auth.js (ESM) — Bearer only, refresh via header/body, forgot/reset password
// 03.2 เพิ่ม auth.js และ lookup.js ให้สอดคล้อง RBAC/permission_code
// ตาม src/config/permissions.js) และอ่าน scope จาก DB เสมอ
/*สมมติว่ามีตาราง password_resets สำหรับเก็บโทเคนรีเซ็ต:
password_resets(
id BIGINT PK, user_id BIGINT, token_hash CHAR(64),
expires_at DATETIME, used_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
INDEX(token_hash), INDEX(user_id), INDEX(expires_at)
*/
import { Router } from "express"; import { Router } from "express";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import sql from "../db/index.js"; import sql from "../db/index.js";
import { cookieOpts } from "../utils/cookie.js";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { requireAuth } from "../middleware/auth.js";
import crypto from "node:crypto"; import crypto from "node:crypto";
const r = Router(); const r = Router();
@@ -89,6 +81,18 @@ r.post("/login", async (req, res) => {
const token = signAccessToken(user); const token = signAccessToken(user);
const refresh_token = signRefreshToken(user); const refresh_token = signRefreshToken(user);
// 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({ return res.json({
token, token,
refresh_token, refresh_token,
@@ -101,54 +105,76 @@ r.post("/login", async (req, res) => {
}, },
}); });
}); });
/* ========================= /* =========================
* POST /api/auth/refresh * GET /api/auth/me (cookie or bearer)
* - รองรับ refresh token จาก:
* 1) Authorization: Bearer <refresh_token>
* 2) req.body.refresh_token
* - ออก token ใหม่ + refresh ใหม่ (rotation)
* ========================= */ * ========================= */
r.post("/refresh", async (req, res) => { r.get("/me", requireAuth, async (req, res) => {
const fromHeader = getBearer(req); return res.json({
const fromBody = (req.body || {}).refresh_token; ok: true,
const refreshToken = fromHeader || fromBody; user: { user_id: req.user.user_id, username: req.user.username },
if (!refreshToken) { });
return res.status(400).json({ error: "REFRESH_TOKEN_REQUIRED" });
}
try {
const payload = jwt.verify(refreshToken, REFRESH_SECRET, {
issuer: "dms-backend",
});
if (payload.t !== "refresh") throw new Error("bad token type");
const [[user]] = await sql.query(
`SELECT user_id, username, email, first_name, last_name
FROM users WHERE user_id=? LIMIT 1`,
[payload.user_id]
);
if (!user) return res.status(401).json({ error: "USER_NOT_FOUND" });
// rotation
const token = signAccessToken(user);
const new_refresh = signRefreshToken(user);
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/refresh
* - รองรับ refresh token จาก:
* 1) Authorization: Bearer <refresh_token>
* 2) req.body.refresh_token
* - ออก token ใหม่ + refresh ใหม่ (rotation)
* ========================= */
r.post("/refresh", async (req, res) => {
const fromHeader = getBearer(req);
const fromBody = (req.body || {}).refresh_token;
const refreshToken = fromHeader || fromBody;
if (!refreshToken) {
return res.status(400).json({ error: "REFRESH_TOKEN_REQUIRED" });
}
try {
const payload = jwt.verify(refreshToken, REFRESH_SECRET, {
issuer: "dms-backend",
});
if (payload.t !== "refresh") throw new Error("bad token type");
const [[user]] = await sql.query(
`SELECT user_id, username, email, first_name, last_name
FROM users WHERE user_id=? LIMIT 1`,
[payload.user_id]
);
if (!user) return res.status(401).json({ error: "USER_NOT_FOUND" });
// rotation
const token = signAccessToken(user);
const new_refresh = signRefreshToken(user);
// rotate cookies
res.cookie(
"access_token",
token,
cookieOpts(parseInt(process.env.ACCESS_TTL_MS || "900000", 10))
);
res.cookie(
"refresh_token",
new_refresh,
cookieOpts(parseInt(process.env.REFRESH_TTL_MS || "604800000", 10))
);
return res.json({
token,
refresh_token: new_refresh,
user: {
user_id: user.user_id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
},
});
} catch {
return res.status(401).json({ error: "INVALID_REFRESH_TOKEN" });
}
})
);
/* ========================= /* =========================
* POST /api/auth/forgot-password * POST /api/auth/forgot-password
@@ -242,6 +268,8 @@ r.post("/reset-password", async (req, res) => {
* - frontend ลบ token เอง * - frontend ลบ token เอง
* ========================= */ * ========================= */
r.post("/logout", (_req, res) => { r.post("/logout", (_req, res) => {
res.clearCookie("access_token", { path: "/" });
res.clearCookie("refresh_token", { path: "/" });
return res.json({ ok: true }); return res.json({ ok: true });
}); });

View File

@@ -1,60 +1,62 @@
// FILE: src/routes/categories.js // FILE: src/routes/categories.js
import { Router } from "express"; // อ่าน: ใช้ organizations.view (GLOBAL)
import sql from "../db/index.js"; // สร้าง/แก้/ลบ: ใช้ settings.manage (GLOBAL)
import { requirePerm } from "../middleware/requirePerm.js"; import { Router } from "express";
import sql from "../db/index.js";
const r = Router(); import { requirePerm } from "../middleware/requirePerm.js";
// Categories const r = Router();
r.get("/categories", requirePerm("organizations.view"), async (_req, res) => {
const [rows] = await sql.query( // Categories
"SELECT * FROM categories ORDER BY cat_id DESC" r.get("/categories", requirePerm("organizations.view"), async (_req, res) => {
); const [rows] = await sql.query(
res.json(rows); "SELECT * FROM categories ORDER BY cat_id DESC"
}); );
r.post("/categories", requirePerm("settings.manage"), async (req, res) => { res.json(rows);
const { cat_code, cat_name } = req.body || {}; });
if (!cat_code || !cat_name) r.post("/categories", requirePerm("settings.manage"), async (req, res) => {
return res.status(400).json({ error: "cat_code and cat_name required" }); const { cat_code, cat_name } = req.body || {};
const [rs] = await sql.query( if (!cat_code || !cat_name)
"INSERT INTO categories (cat_code, cat_name) VALUES (?,?)", return res.status(400).json({ error: "cat_code and cat_name required" });
[cat_code, cat_name] const [rs] = await sql.query(
); "INSERT INTO categories (cat_code, cat_name) VALUES (?,?)",
res.json({ cat_id: rs.insertId }); [cat_code, cat_name]
}); );
r.put("/categories/:id", requirePerm("settings.manage"), async (req, res) => { res.json({ cat_id: rs.insertId });
const id = Number(req.params.id); });
const { cat_name } = req.body || {}; r.put("/categories/:id", requirePerm("settings.manage"), async (req, res) => {
await sql.query("UPDATE categories SET cat_name=? WHERE cat_id=?", [ const id = Number(req.params.id);
cat_name, const { cat_name } = req.body || {};
id, await sql.query("UPDATE categories SET cat_name=? WHERE cat_id=?", [
]); cat_name,
res.json({ ok: 1 }); id,
}); ]);
r.delete( res.json({ ok: 1 });
"/categories/:id", });
requirePerm("settings.manage"), r.delete(
async (req, res) => { "/categories/:id",
const id = Number(req.params.id); requirePerm("settings.manage"),
await sql.query("DELETE FROM categories WHERE cat_id=?", [id]); async (req, res) => {
res.json({ ok: 1 }); const id = Number(req.params.id);
} await sql.query("DELETE FROM categories WHERE cat_id=?", [id]);
); res.json({ ok: 1 });
}
// Subcategories );
r.get("/subcategories", requirePerm("organizations.view"), async (req, res) => {
const { cat_id } = req.query; // Subcategories
const params = []; r.get("/subcategories", requirePerm("organizations.view"), async (req, res) => {
let where = ""; const { cat_id } = req.query;
if (cat_id) { const params = [];
where = " WHERE cat_id=?"; let where = "";
params.push(Number(cat_id)); if (cat_id) {
} where = " WHERE cat_id=?";
const [rows] = await sql.query( params.push(Number(cat_id));
`SELECT * FROM subcategories${where} ORDER BY sub_cat_id DESC`, }
params const [rows] = await sql.query(
); `SELECT * FROM subcategories${where} ORDER BY sub_cat_id DESC`,
res.json(rows); params
}); );
res.json(rows);
export default r; });
export default r;

View File

@@ -1,141 +1,143 @@
// FILE: src/routes/contract_dwg.js // FILE: src/routes/contract_dwg.js
import { Router } from "express"; // ใน seed ยังไม่มี contract_dwg.* → ผูกชั่วคราวกับสิทธิ์กลุ่ม drawings:
import sql from "../db/index.js"; // read → drawings.view, create/update/delete → drawings.upload/delete (PROJECT scope)
import { requirePerm } from "../middleware/requirePerm.js"; import { Router } from "express";
import sql from "../db/index.js";
const r = Router(); import { requirePerm } from "../middleware/requirePerm.js";
// LIST (PROJECT scope enforce via params ifส่ง project_id, ไม่ส่งจะถูก filter ด้วย scope ภายใน) const r = Router();
r.get(
"/", // LIST (PROJECT scope enforce via params ifส่ง project_id, ไม่ส่งจะถูก filter ด้วย scope ภายใน)
requirePerm("drawings.view", { projectParam: "project_id" }), r.get(
async (req, res) => { "/",
const { project_id, org_id, condwg_no, limit = 50, offset = 0 } = req.query; requirePerm("drawings.view", { projectParam: "project_id" }),
const p = req.principal; async (req, res) => {
const params = []; const { project_id, org_id, condwg_no, limit = 50, offset = 0 } = req.query;
const cond = []; const p = req.principal;
const params = [];
// ABAC filter ฝั่ง server กันหลุดขอบเขต const cond = [];
if (!p.is_superadmin) {
if (project_id) { // ABAC filter ฝั่ง server กันหลุดขอบเขต
if (!p.inProject(Number(project_id))) if (!p.is_superadmin) {
return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); if (project_id) {
cond.push("m.project_id=?"); if (!p.inProject(Number(project_id)))
params.push(Number(project_id)); return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
} else if (p.project_ids?.length) { cond.push("m.project_id=?");
cond.push( params.push(Number(project_id));
`m.project_id IN (${p.project_ids.map(() => "?").join(",")})` } else if (p.project_ids?.length) {
); cond.push(
params.push(...p.project_ids); `m.project_id IN (${p.project_ids.map(() => "?").join(",")})`
} );
} else if (project_id) { params.push(...p.project_ids);
cond.push("m.project_id=?"); }
params.push(Number(project_id)); } 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 (org_id) {
} cond.push("m.org_id=?");
if (condwg_no) { params.push(Number(org_id));
cond.push("m.condwg_no=?"); }
params.push(condwg_no); 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 ?`, const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
[...params, Number(limit), Number(offset)] const [rows] = await sql.query(
); `SELECT m.* FROM contract_dwg m ${where} ORDER BY m.id DESC LIMIT ? OFFSET ?`,
res.json(rows); [...params, Number(limit), Number(offset)]
} );
); res.json(rows);
}
// GET item (ตรวจ ABAC หลังอ่านเรคคอร์ด) );
r.get("/:id", requirePerm("drawings.view"), async (req, res) => {
const id = Number(req.params.id); // GET item (ตรวจ ABAC หลังอ่านเรคคอร์ด)
const [[row]] = await sql.query("SELECT * FROM contract_dwg WHERE id=?", [ r.get("/:id", requirePerm("drawings.view"), async (req, res) => {
id, const id = Number(req.params.id);
]); const [[row]] = await sql.query("SELECT * FROM contract_dwg WHERE id=?", [
if (!row) return res.status(404).json({ error: "Not found" }); id,
const p = req.principal; ]);
if (!p.is_superadmin && !p.inProject(row.project_id)) if (!row) return res.status(404).json({ error: "Not found" });
return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); const p = req.principal;
res.json(row); if (!p.is_superadmin && !p.inProject(row.project_id))
}); return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
res.json(row);
// CREATE });
r.post(
"/", // CREATE
requirePerm("drawings.upload", { projectParam: "project_id" }), r.post(
async (req, res) => { "/",
const { requirePerm("drawings.upload", { projectParam: "project_id" }),
org_id, async (req, res) => {
project_id, const {
condwg_no, org_id,
title, project_id,
drawing_id, condwg_no,
volume_id, title,
sub_cat_id, drawing_id,
sub_no, volume_id,
remark, sub_cat_id,
} = req.body || {}; sub_no,
if (!project_id || !condwg_no) remark,
return res } = req.body || {};
.status(400) if (!project_id || !condwg_no)
.json({ error: "project_id and condwg_no required" }); return res
const [rs] = await sql.query( .status(400)
`INSERT INTO contract_dwg (org_id, project_id, condwg_no, title, drawing_id, volume_id, sub_cat_id, sub_no, remark, created_by) .json({ error: "project_id and condwg_no required" });
VALUES (?,?,?,?,?,?,?,?,?,?)`, 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)
org_id || null, VALUES (?,?,?,?,?,?,?,?,?,?)`,
project_id, [
condwg_no, org_id || null,
title || null, project_id,
drawing_id || null, condwg_no,
volume_id || null, title || null,
sub_cat_id || null, drawing_id || null,
sub_no || null, volume_id || null,
remark || null, sub_cat_id || null,
req.principal.user_id, sub_no || null,
] remark || null,
); req.principal.user_id,
res.json({ id: rs.insertId }); ]
} );
); res.json({ id: rs.insertId });
}
// UPDATE );
r.put("/:id", requirePerm("drawings.upload"), async (req, res) => {
const id = Number(req.params.id); // UPDATE
const [[row]] = await sql.query("SELECT * FROM contract_dwg WHERE id=?", [ r.put("/:id", requirePerm("drawings.upload"), async (req, res) => {
id, const id = Number(req.params.id);
]); const [[row]] = await sql.query("SELECT * FROM contract_dwg WHERE id=?", [
if (!row) return res.status(404).json({ error: "Not found" }); id,
const p = req.principal; ]);
if (!p.is_superadmin && !p.inProject(row.project_id)) if (!row) return res.status(404).json({ error: "Not found" });
return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); const p = req.principal;
if (!p.is_superadmin && !p.inProject(row.project_id))
const { title, remark } = req.body || {}; return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
await sql.query("UPDATE contract_dwg SET title=?, remark=? WHERE id=?", [
title ?? row.title, const { title, remark } = req.body || {};
remark ?? row.remark, await sql.query("UPDATE contract_dwg SET title=?, remark=? WHERE id=?", [
id, title ?? row.title,
]); remark ?? row.remark,
res.json({ ok: 1 }); id,
}); ]);
res.json({ ok: 1 });
// DELETE });
r.delete("/:id", requirePerm("drawings.delete"), async (req, res) => {
const id = Number(req.params.id); // DELETE
const [[row]] = await sql.query("SELECT * FROM contract_dwg WHERE id=?", [ r.delete("/:id", requirePerm("drawings.delete"), async (req, res) => {
id, const id = Number(req.params.id);
]); const [[row]] = await sql.query("SELECT * FROM contract_dwg WHERE id=?", [
if (!row) return res.status(404).json({ error: "Not found" }); id,
const p = req.principal; ]);
if (!p.is_superadmin && !p.inProject(row.project_id)) if (!row) return res.status(404).json({ error: "Not found" });
return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); const p = req.principal;
await sql.query("DELETE FROM contract_dwg WHERE id=?", [id]); if (!p.is_superadmin && !p.inProject(row.project_id))
res.json({ ok: 1 }); return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
}); await sql.query("DELETE FROM contract_dwg WHERE id=?", [id]);
res.json({ ok: 1 });
export default r; });
export default r;

View File

@@ -1,138 +1,141 @@
// FILE: src/routes/contracts.js // FILE: src/routes/contracts.js
import { Router } from "express"; // ไม่มี contract.* ใน seed → map เป็นงานดูแลองค์กร/โปรเจ็กต์:
import sql from "../db/index.js"; // list/get → projects.view (ORG)
import { requirePerm } from "../middleware/requirePerm.js"; // create/update/delete → projects.manage (ORG)
import { Router } from "express";
const r = Router(); import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
// LIST
r.get( const r = Router();
"/",
requirePerm("projects.view", { orgParam: "org_id" }), // LIST
async (req, res) => { r.get(
const { "/",
project_id, requirePerm("projects.view", { orgParam: "org_id" }),
org_id, async (req, res) => {
contract_no, const {
q, project_id,
limit = 50, org_id,
offset = 0, contract_no,
} = req.query; q,
const p = req.principal; limit = 50,
const params = []; offset = 0,
const cond = []; } = req.query;
if (!p.is_superadmin) { const p = req.principal;
if (org_id) { const params = [];
if (!p.inOrg(Number(org_id))) const cond = [];
return res.status(403).json({ error: "FORBIDDEN_ORG" }); if (!p.is_superadmin) {
cond.push("c.org_id=?"); if (org_id) {
params.push(Number(org_id)); if (!p.inOrg(Number(org_id)))
} else if (p.org_ids?.length) { return res.status(403).json({ error: "FORBIDDEN_ORG" });
cond.push(`c.org_id IN (${p.org_ids.map(() => "?").join(",")})`); cond.push("c.org_id=?");
params.push(...p.org_ids); params.push(Number(org_id));
} } else if (p.org_ids?.length) {
} else if (org_id) { cond.push(`c.org_id IN (${p.org_ids.map(() => "?").join(",")})`);
cond.push("c.org_id=?"); params.push(...p.org_ids);
params.push(Number(org_id)); }
} } else if (org_id) {
cond.push("c.org_id=?");
if (project_id) { params.push(Number(org_id));
cond.push("c.project_id=?"); }
params.push(Number(project_id));
} if (project_id) {
if (contract_no) { cond.push("c.project_id=?");
cond.push("c.contract_no=?"); params.push(Number(project_id));
params.push(contract_no); }
} if (contract_no) {
if (q) { cond.push("c.contract_no=?");
cond.push("(c.contract_no LIKE ? OR c.title LIKE ?)"); params.push(contract_no);
params.push(`%${q}%`, `%${q}%`); }
} if (q) {
cond.push("(c.contract_no LIKE ? OR c.title LIKE ?)");
const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; params.push(`%${q}%`, `%${q}%`);
const [rows] = await sql.query( }
`SELECT c.* FROM contracts c ${where} ORDER BY c.id DESC LIMIT ? OFFSET ?`,
[...params, Number(limit), Number(offset)] const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
); const [rows] = await sql.query(
res.json(rows); `SELECT c.* FROM contracts c ${where} ORDER BY c.id DESC LIMIT ? OFFSET ?`,
} [...params, Number(limit), Number(offset)]
); );
res.json(rows);
// GET }
r.get( );
"/:id",
requirePerm("projects.view", { orgParam: "org_id" }), // GET
async (req, res) => { r.get(
const id = Number(req.params.id); "/:id",
const [[row]] = await sql.query("SELECT * FROM contracts WHERE id=?", [id]); requirePerm("projects.view", { orgParam: "org_id" }),
if (!row) return res.status(404).json({ error: "Not found" }); async (req, res) => {
const p = req.principal; const id = Number(req.params.id);
if (!p.is_superadmin && !p.inOrg(row.org_id)) const [[row]] = await sql.query("SELECT * FROM contracts WHERE id=?", [id]);
return res.status(403).json({ error: "FORBIDDEN_ORG" }); if (!row) return res.status(404).json({ error: "Not found" });
res.json(row); const p = req.principal;
} if (!p.is_superadmin && !p.inOrg(row.org_id))
); return res.status(403).json({ error: "FORBIDDEN_ORG" });
res.json(row);
// CREATE }
r.post( );
"/",
requirePerm("projects.manage", { orgParam: "org_id" }), // CREATE
async (req, res) => { r.post(
const { org_id, project_id, contract_no, title, status } = req.body || {}; "/",
if (!org_id || !project_id || !contract_no) requirePerm("projects.manage", { orgParam: "org_id" }),
return res async (req, res) => {
.status(400) const { org_id, project_id, contract_no, title, status } = req.body || {};
.json({ error: "org_id, project_id, contract_no required" }); if (!org_id || !project_id || !contract_no)
const [rs] = await sql.query( return res
`INSERT INTO contracts (org_id, project_id, contract_no, title, status, created_by) VALUES (?,?,?,?,?,?)`, .status(400)
[ .json({ error: "org_id, project_id, contract_no required" });
org_id, const [rs] = await sql.query(
project_id, `INSERT INTO contracts (org_id, project_id, contract_no, title, status, created_by) VALUES (?,?,?,?,?,?)`,
contract_no, [
title || null, org_id,
status || null, project_id,
req.principal.user_id, contract_no,
] title || null,
); status || null,
res.json({ id: rs.insertId }); req.principal.user_id,
} ]
); );
res.json({ id: rs.insertId });
// UPDATE }
r.put( );
"/:id",
requirePerm("projects.manage", { orgParam: "org_id" }), // UPDATE
async (req, res) => { r.put(
const id = Number(req.params.id); "/:id",
const [[row]] = await sql.query("SELECT * FROM contracts WHERE id=?", [id]); requirePerm("projects.manage", { orgParam: "org_id" }),
if (!row) return res.status(404).json({ error: "Not found" }); async (req, res) => {
const p = req.principal; const id = Number(req.params.id);
if (!p.is_superadmin && !p.inOrg(row.org_id)) const [[row]] = await sql.query("SELECT * FROM contracts WHERE id=?", [id]);
return res.status(403).json({ error: "FORBIDDEN_ORG" }); if (!row) return res.status(404).json({ error: "Not found" });
const { title, status } = req.body || {}; const p = req.principal;
await sql.query("UPDATE contracts SET title=?, status=? WHERE id=?", [ if (!p.is_superadmin && !p.inOrg(row.org_id))
title ?? row.title, return res.status(403).json({ error: "FORBIDDEN_ORG" });
status ?? row.status, const { title, status } = req.body || {};
id, await sql.query("UPDATE contracts SET title=?, status=? WHERE id=?", [
]); title ?? row.title,
res.json({ ok: 1 }); status ?? row.status,
} id,
); ]);
res.json({ ok: 1 });
// DELETE }
r.delete( );
"/:id",
requirePerm("projects.manage", { orgParam: "org_id" }), // DELETE
async (req, res) => { r.delete(
const id = Number(req.params.id); "/:id",
const [[row]] = await sql.query("SELECT * FROM contracts WHERE id=?", [id]); requirePerm("projects.manage", { orgParam: "org_id" }),
if (!row) return res.status(404).json({ error: "Not found" }); async (req, res) => {
const p = req.principal; const id = Number(req.params.id);
if (!p.is_superadmin && !p.inOrg(row.org_id)) const [[row]] = await sql.query("SELECT * FROM contracts WHERE id=?", [id]);
return res.status(403).json({ error: "FORBIDDEN_ORG" }); if (!row) return res.status(404).json({ error: "Not found" });
await sql.query("DELETE FROM contracts WHERE id=?", [id]); const p = req.principal;
res.json({ ok: 1 }); if (!p.is_superadmin && !p.inOrg(row.org_id))
} return res.status(403).json({ error: "FORBIDDEN_ORG" });
); await sql.query("DELETE FROM contracts WHERE id=?", [id]);
res.json({ ok: 1 });
export default r; }
);
export default r;

View File

@@ -0,0 +1,56 @@
// backend/src/routes/dashboard.js
import { Router } from "express";
import { Op } from "sequelize";
import { Correspondence, Document, RFA, User } from "../db/index.js"; // import models
import { authJwt } from "../middleware/index.js";
const router = Router();
// Middleware: ตรวจสอบสิทธิ์สำหรับทุก route ในไฟล์นี้
router.use(authJwt.verifyToken);
// === API สำหรับ User Management Widget ===
router.get("/users/summary", async (req, res, next) => {
try {
const totalUsers = await User.count();
const activeUsers = await User.count({ where: { is_active: true } });
// ดึง user ที่สร้างล่าสุด 5 คน
const recentUsers = await User.findAll({
limit: 5,
order: [["createdAt", "DESC"]],
attributes: ["id", "username", "email", "createdAt"],
});
res.json({
total: totalUsers,
active: activeUsers,
inactive: totalUsers - activeUsers,
recent: recentUsers,
});
} catch (error) {
next(error);
}
});
// === API อื่นๆ สำหรับ Dashboard ที่เราคุยกันไว้ก่อนหน้า ===
router.get("/stats", async (req, res, next) => {
try {
const sevenDaysAgo = new Date(new Date().setDate(new Date().getDate() - 7));
const totalDocuments = await Document.count();
const newThisWeek = await Document.count({
where: { createdAt: { [Op.gte]: sevenDaysAgo } },
});
const pendingRfas = await RFA.count({ where: { status: "pending" } }); // สมมติตาม status
res.json({
totalDocuments,
newThisWeek,
pendingRfas,
});
} catch (error) {
next(error);
}
});
export default router;

63
backend/src/routes/dashboard.js Executable file
View File

@@ -0,0 +1,63 @@
// backend/src/routes/dashboard.js
import { Router } from 'express';
import { Op } from 'sequelize';
// 1. Import Middleware ที่ถูกต้อง
import { authJwt } from '../middleware/authJwt.js';
import { loadPrincipalMw } from '../middleware/loadPrincipal.js';
// 2. Import Sequelize Models จาก `sequelize.js` ไม่ใช่ `index.js`
import { Correspondence, Document, RFA, User } from '../db/sequelize.js';
const router = Router();
// 3. ใช้ Middleware Chain ที่ถูกต้อง 100%
router.use(authJwt(), loadPrincipalMw());
// === API สำหรับ User Management Widget ===
router.get('/users/summary', async (req, res, next) => {
try {
// ตรวจสอบว่า Model ถูกโหลดแล้วหรือยัง (จำเป็นสำหรับโหมด lazy-load)
if (!User) {
return res.status(503).json({ message: 'Database models not available. Is ENABLE_SEQUELIZE=1 set?' });
}
const totalUsers = await User.count();
const activeUsers = await User.count({ where: { is_active: true } });
res.json({
total: totalUsers,
active: activeUsers,
inactive: totalUsers - activeUsers,
});
} catch (error) {
next(error);
}
});
// === API อื่นๆ สำหรับ Dashboard ที่เราคุยกันไว้ก่อนหน้า ===
router.get('/stats', async (req, res, next) => {
try {
if (!Document || !RFA) {
return res.status(503).json({ message: 'Database models not available. Is ENABLE_SEQUELIZE=1 set?' });
}
const sevenDaysAgo = new Date(new Date().setDate(new Date().getDate() - 7));
const totalDocuments = await Document.count();
const newThisWeek = await Document.count({ where: { createdAt: { [Op.gte]: sevenDaysAgo } } });
const pendingRfas = await RFA.count({ where: { status: 'pending' } }); // สมมติตาม status
res.json({
totalDocuments,
newThisWeek,
pendingRfas
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -1,4 +1,5 @@
// FILE: backend/src/routes/mvp.js // FILE: backend/src/routes/mvp.js
// (generic entity maps — ใช้ projects.view อ่าน และ projects.manage เขียน/ลบ)
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";

View File

@@ -1,16 +1,16 @@
// FILE: backend/src/routes/permissions.js // FILE: backend/src/routes/permissions.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";
const r = Router(); const r = Router();
// GLOBAL: settings.manage จึงเห็นได้ทั้งหมด // GLOBAL: settings.manage จึงเห็นได้ทั้งหมด
r.get("/", requirePerm("settings.manage"), async (_req, res) => { r.get("/", requirePerm("settings.manage"), async (_req, res) => {
const [rows] = await sql.query( const [rows] = await sql.query(
"SELECT permission_id, perm_code AS permission_code, scope_level, description FROM permissions ORDER BY perm_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;

View File

@@ -0,0 +1,126 @@
// FILE: backend/src/routes/rbac_admin.js
// RBAC admin — ใช้ settings.manage ทั้งหมด
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
// ROLES
r.get("/roles", requirePerm("settings.manage"), async (_req, res) => {
const [rows] = await sql.query(
"SELECT role_id, role_code, role_name, description FROM roles ORDER BY role_code"
);
res.json(rows);
});
// PERMISSIONS
r.get("/permissions", requirePerm("settings.manage"), async (_req, res) => {
const [rows] = await sql.query(
"SELECT permission_id, perm_code AS permission_code, scope_level, description FROM permissions ORDER BY perm_code"
);
res.json(rows);
});
// role -> permissions
r.get(
"/roles/:role_id/permissions",
requirePerm("settings.manage"),
async (req, res) => {
const role_id = Number(req.params.role_id);
const [rows] = await sql.query(
`SELECT p.permission_id, p.perm_code AS permission_code, p.description
FROM role_permissions rp
JOIN permissions p ON p.permission_id = rp.permission_id
WHERE rp.role_id=? ORDER BY p.perm_code`,
[role_id]
);
res.json(rows);
}
);
r.post(
"/roles/:role_id/permissions",
requirePerm("settings.manage"),
async (req, res) => {
const role_id = Number(req.params.role_id);
const { permission_id } = req.body || {};
await sql.query(
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?,?)",
[role_id, Number(permission_id)]
);
res.json({ ok: 1 });
}
);
r.delete(
"/roles/:role_id/permissions/:permission_id",
requirePerm("settings.manage"),
async (req, res) => {
const role_id = Number(req.params.role_id);
const permission_id = Number(req.params.permission_id);
await sql.query(
"DELETE FROM role_permissions WHERE role_id=? AND permission_id=?",
[role_id, permission_id]
);
res.json({ ok: 1 });
}
);
// user -> roles (global/org/project scope columns มีในตาราง user_roles ตามสคีมา)
r.get(
"/users/:user_id/roles",
requirePerm("settings.manage"),
async (req, res) => {
const user_id = Number(req.params.user_id);
const [rows] = await sql.query(
`SELECT ur.user_id, ur.role_id, r.role_code, r.role_name, ur.org_id, ur.project_id
FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id
WHERE ur.user_id=? ORDER BY r.role_code`,
[user_id]
);
res.json(rows);
}
);
r.post(
"/users/:user_id/roles",
requirePerm("settings.manage"),
async (req, res) => {
const user_id = Number(req.params.user_id);
const { role_id, org_id = null, project_id = null } = req.body || {};
await sql.query(
"INSERT INTO user_roles (user_id, role_id, org_id, project_id) VALUES (?,?,?,?)",
[
user_id,
Number(role_id),
org_id ? Number(org_id) : null,
project_id ? Number(project_id) : null,
]
);
res.json({ ok: 1 });
}
);
r.delete(
"/users/:user_id/roles",
requirePerm("settings.manage"),
async (req, res) => {
const user_id = Number(req.params.user_id);
const { role_id, org_id = null, project_id = null } = req.body || {};
// สร้างเงื่อนไขแบบ dynamic สำหรับ NULL-safe compare
const whereOrg = org_id === null ? "ur.org_id IS NULL" : "ur.org_id = ?";
const wherePrj =
project_id === null ? "ur.project_id IS NULL" : "ur.project_id = ?";
const params = [user_id, Number(role_id)];
if (org_id !== null) params.push(Number(org_id));
if (project_id !== null) params.push(Number(project_id));
await sql.query(
`DELETE FROM user_roles ur WHERE ur.user_id=? AND ur.role_id=? AND ${whereOrg} AND ${wherePrj}`,
params
);
res.json({ ok: 1 });
}
);
export default r;

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

@@ -1,126 +1,88 @@
// FILE: backend/src/routes/rbac_admin.js // FILE: backend/src/routes/rbac_admin.js
// RBAC admin — ใช้ settings.manage ทั้งหมด
import { Router } from "express"; import { Router } from "express";
import sql from "../db/index.js"; import { Role, Permission, UserProjectRole, Project } from "../db/sequelize.js";
import { authJwt } from "../middleware/authJwt.js";
import { loadPrincipalMw } from "../middleware/loadPrincipal.js"; // แก้ไข: import ให้ถูกต้อง
import { requirePerm } from "../middleware/requirePerm.js"; import { requirePerm } from "../middleware/requirePerm.js";
const r = Router(); const router = Router();
// ROLES // Middleware Chain ที่ถูกต้อง 100% ตามสถาปัตยกรรมของคุณ
r.get("/roles", requirePerm("settings.manage"), async (_req, res) => { router.use(authJwt(), loadPrincipalMw());
const [rows] = await sql.query(
"SELECT role_id, role_code, role_name, description FROM roles ORDER BY role_code" // == ROLES Management ==
); 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); }
}); });
// PERMISSIONS router.post("/roles", requirePerm("roles.manage"), async (req, res, next) => {
r.get("/permissions", requirePerm("settings.manage"), async (_req, res) => { const { name, description } = req.body;
const [rows] = await sql.query( if (!name) return res.status(400).json({ message: "Role name is required." });
"SELECT permission_id, perm_code AS permission_code, scope_level, description FROM permissions ORDER BY perm_code" try {
); const newRole = await Role.create({ name, description });
res.json(rows); res.status(201).json(newRole);
} catch (error) {
if (error.name === "SequelizeUniqueConstraintError") {
return res.status(409).json({ message: `Role '${name}' already exists.` });
}
next(error);
}
}); });
// role -> permissions router.put("/roles/:id/permissions", requirePerm("roles.manage"), async (req, res, next) => {
r.get( const { permissionIds } = req.body;
"/roles/:role_id/permissions", if (!Array.isArray(permissionIds)) return res.status(400).json({ message: "permissionIds must be an array." });
requirePerm("settings.manage"), try {
async (req, res) => { const role = await Role.findByPk(req.params.id);
const role_id = Number(req.params.role_id); if (!role) return res.status(404).json({ message: "Role not found." });
const [rows] = await sql.query( await role.setPermissions(permissionIds);
`SELECT p.permission_id, p.perm_code AS permission_code, p.description const updatedRole = await Role.findByPk(req.params.id, {
FROM role_permissions rp include: [{ model: Permission, attributes: ['id', 'name'], through: { attributes: [] } }]
JOIN permissions p ON p.permission_id = rp.permission_id });
WHERE rp.role_id=? ORDER BY p.perm_code`, res.json(updatedRole);
[role_id] } catch (error) { next(error); }
); });
res.json(rows);
}
);
r.post( // == USER-PROJECT-ROLES Management ==
"/roles/:role_id/permissions", router.get("/user-project-roles", requirePerm("users.manage"), async (req, res, next) => {
requirePerm("settings.manage"), 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( where: { user_id: userId },
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?,?)", include: [ { model: Project, attributes: ["id", "name"] }, { model: Role, attributes: ["id", "name"] } ],
[role_id, Number(permission_id)] });
); res.json(assignments);
res.json({ ok: 1 }); } catch (error) { next(error); }
} });
);
r.delete( router.post("/user-project-roles", requirePerm("users.manage"), async (req, res, next) => {
"/roles/:role_id/permissions/:permission_id", const { userId, projectId, roleId } = req.body;
requirePerm("settings.manage"), if (!userId || !projectId || !roleId) return res.status(400).json({ message: "userId, projectId, and roleId are required." });
async (req, res) => { try {
const role_id = Number(req.params.role_id); const [assignment, created] = await UserProjectRole.findOrCreate({
const permission_id = Number(req.params.permission_id); where: { user_id: userId, project_id: projectId, role_id: roleId },
await sql.query( defaults: { user_id: userId, project_id: projectId, role_id: roleId },
"DELETE FROM role_permissions WHERE role_id=? AND permission_id=?", });
[role_id, permission_id] if (!created) return res.status(409).json({ message: "This assignment already exists." });
); res.status(201).json(assignment);
res.json({ ok: 1 }); } catch (error) { next(error); }
} });
);
// user -> roles (global/org/project scope columns มีในตาราง user_roles ตามสคีมา) router.delete("/user-project-roles", requirePerm("users.manage"), async (req, res, next) => {
r.get( const { userId, projectId, roleId } = req.body;
"/users/:user_id/roles", if (!userId || !projectId || !roleId) return res.status(400).json({ message: "userId, projectId, and roleId are required." });
requirePerm("settings.manage"), try {
async (req, res) => { const deletedCount = await UserProjectRole.destroy({ where: { user_id: userId, project_id: projectId, role_id: roleId } });
const user_id = Number(req.params.user_id); if (deletedCount === 0) return res.status(404).json({ message: 'Assignment not found.' });
const [rows] = await sql.query( res.status(204).send();
`SELECT ur.user_id, ur.role_id, r.role_code, r.role_name, ur.org_id, ur.project_id } catch (error) { next(error); }
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( export default router;
"/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;

View File

@@ -1,91 +1,91 @@
// FILE: backend/src/routes/rfa.js // FILE: backend/src/routes/rfa.js
// RFA: create + update-status ผ่าน stored procedures // RFA: create + update-status ผ่าน stored procedures
import { Router } from "express"; import { Router } from "express";
import sql, { callProc } from "../db/index.js"; import sql, { callProc } from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js"; import { requirePerm } from "../middleware/requirePerm.js";
const r = Router(); const r = Router();
// CREATE (PROJECT scope) -> rfas.create // CREATE (PROJECT scope) -> rfas.create
r.post( r.post(
"/create", "/create",
requirePerm("rfas.create", { projectParam: "project_id" }), requirePerm("rfas.create", { projectParam: "project_id" }),
async (req, res, next) => { async (req, res, next) => {
try { try {
const { const {
project_id, project_id,
cor_status_id, cor_status_id,
cor_no, cor_no,
title, title,
originator_id, originator_id,
recipient_id, recipient_id,
keywords = null, keywords = null,
pdf_path = null, pdf_path = null,
item_doc_ids = [], item_doc_ids = [],
} = req.body || {}; } = req.body || {};
if (!project_id || !title) { if (!project_id || !title) {
return res.status(400).json({ error: "project_id and title required" }); return res.status(400).json({ error: "project_id and title required" });
} }
const json = JSON.stringify((item_doc_ids || []).map(Number)); const json = JSON.stringify((item_doc_ids || []).map(Number));
await callProc("sp_rfa_create_with_items", [ await callProc("sp_rfa_create_with_items", [
req.principal.user_id, req.principal.user_id,
project_id, project_id,
cor_status_id ?? null, cor_status_id ?? null,
cor_no ?? null, cor_no ?? null,
title, title,
originator_id ?? null, originator_id ?? null,
recipient_id ?? null, recipient_id ?? null,
keywords, keywords,
pdf_path, pdf_path,
json, json,
null, null,
]); ]);
res.status(201).json({ ok: true }); res.status(201).json({ ok: true });
} catch (e) { } catch (e) {
next(e); next(e);
} }
} }
); );
// UPDATE STATUS (PROJECT scope) -> rfas.respond // UPDATE STATUS (PROJECT scope) -> rfas.respond
r.post( r.post(
"/update-status", "/update-status",
requirePerm("rfas.respond"), 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 || {};
if (!rfa_corr_id || !status_id) { if (!rfa_corr_id || !status_id) {
return res return res
.status(400) .status(400)
.json({ error: "rfa_corr_id and status_id required" }); .json({ error: "rfa_corr_id and status_id required" });
} }
// enforce ABAC: find project_id of the RFA // enforce ABAC: find project_id of the RFA
const [[ref]] = await sql.query( const [[ref]] = await sql.query(
"SELECT project_id FROM rfas WHERE id=? LIMIT 1", "SELECT project_id FROM rfas WHERE id=? LIMIT 1",
[Number(rfa_corr_id)] [Number(rfa_corr_id)]
); );
if (!ref) return res.status(404).json({ error: "RFA not found" }); if (!ref) return res.status(404).json({ error: "RFA not found" });
if ( if (
!req.principal.is_superadmin && !req.principal.is_superadmin &&
!req.principal.inProject(ref.project_id) !req.principal.inProject(ref.project_id)
) { ) {
return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
} }
await callProc("sp_rfa_update_status", [ await callProc("sp_rfa_update_status", [
req.principal.user_id, req.principal.user_id,
rfa_corr_id, rfa_corr_id,
status_id, status_id,
set_issue ? 1 : 0, set_issue ? 1 : 0,
]); ]);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
next(e); next(e);
} }
} }
); );
export default r; export default r;

View File

@@ -1,124 +1,124 @@
// FILE: backend/src/routes/technicaldocs.js // FILE: backend/src/routes/technicaldocs.js
// แมปเป็นเอกสารประเภทหนึ่ง → ใช้สิทธิ์ documents.view/manage (PROJECT) // แมปเป็นเอกสารประเภทหนึ่ง → ใช้สิทธิ์ documents.view/manage (PROJECT)
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";
const r = Router(); const r = Router();
// LIST // LIST
r.get( r.get(
"/", "/",
requirePerm("documents.view", { projectParam: "project_id" }), requirePerm("documents.view", { projectParam: "project_id" }),
async (req, res) => { async (req, res) => {
const { project_id, status, q, limit = 50, offset = 0 } = req.query; const { project_id, status, q, limit = 50, offset = 0 } = req.query;
const P = req.principal; const P = req.principal;
const cond = []; const cond = [];
const params = []; const params = [];
if (!P.is_superadmin) { if (!P.is_superadmin) {
if (project_id) { if (project_id) {
const pid = Number(project_id); const pid = Number(project_id);
if (!P.inProject(pid)) if (!P.inProject(pid))
return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
cond.push("t.project_id=?"); cond.push("t.project_id=?");
params.push(pid); params.push(pid);
} else if (P.project_ids?.length) { } else if (P.project_ids?.length) {
cond.push( cond.push(
`t.project_id IN (${P.project_ids.map(() => "?").join(",")})` `t.project_id IN (${P.project_ids.map(() => "?").join(",")})`
); );
params.push(...P.project_ids); params.push(...P.project_ids);
} }
} else if (project_id) { } else if (project_id) {
cond.push("t.project_id=?"); cond.push("t.project_id=?");
params.push(Number(project_id)); params.push(Number(project_id));
} }
if (status) { if (status) {
cond.push("t.status=?"); cond.push("t.status=?");
params.push(status); params.push(status);
} }
if (q) { if (q) {
cond.push("(t.doc_no LIKE ? OR t.title LIKE ?)"); cond.push("(t.doc_no LIKE ? OR t.title LIKE ?)");
params.push(`%${q}%`, `%${q}%`); params.push(`%${q}%`, `%${q}%`);
} }
const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; const where = cond.length ? `WHERE ${cond.join(" AND ")}` : "";
const [rows] = await sql.query( const [rows] = await sql.query(
`SELECT t.* FROM technicaldocs t ${where} ORDER BY t.id DESC LIMIT ? OFFSET ?`, `SELECT t.* FROM technicaldocs t ${where} ORDER BY t.id DESC LIMIT ? OFFSET ?`,
[...params, Number(limit), Number(offset)] [...params, Number(limit), Number(offset)]
); );
res.json(rows); res.json(rows);
} }
); );
// GET // GET
r.get("/:id", requirePerm("documents.view"), async (req, res) => { r.get("/:id", requirePerm("documents.view"), 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=?", [ const [[row]] = await sql.query("SELECT * FROM technicaldocs WHERE id=?", [
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; const P = req.principal;
if (!P.is_superadmin && !P.inProject(row.project_id)) if (!P.is_superadmin && !P.inProject(row.project_id))
return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
res.json(row); res.json(row);
}); });
// CREATE // CREATE
r.post( r.post(
"/", "/",
requirePerm("documents.manage", { projectParam: "project_id" }), 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) if (!project_id || !doc_no)
return res.status(400).json({ error: "project_id and doc_no required" }); 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 ?? null, org_id ?? null,
project_id, project_id,
doc_no, doc_no,
title ?? null, title ?? null,
status ?? null, status ?? null,
req.principal.user_id, req.principal.user_id,
] ]
); );
res.status(201).json({ id: rs.insertId }); res.status(201).json({ id: rs.insertId });
} }
); );
// UPDATE // UPDATE
r.put("/:id", requirePerm("documents.manage"), async (req, res) => { r.put("/:id", requirePerm("documents.manage"), 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=?", [ const [[row]] = await sql.query("SELECT * FROM technicaldocs WHERE id=?", [
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; const P = req.principal;
if (!P.is_superadmin && !P.inProject(row.project_id)) if (!P.is_superadmin && !P.inProject(row.project_id))
return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
const { title, status } = req.body || {}; const { title, status } = req.body || {};
await sql.query("UPDATE technicaldocs SET title=?, status=? WHERE id=?", [ await sql.query("UPDATE technicaldocs SET title=?, status=? WHERE id=?", [
title ?? row.title, title ?? row.title,
status ?? row.status, status ?? row.status,
id, id,
]); ]);
res.json({ ok: 1 }); res.json({ ok: 1 });
}); });
// DELETE // DELETE
r.delete("/:id", requirePerm("documents.manage"), async (req, res) => { r.delete("/:id", requirePerm("documents.manage"), 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=?", [ const [[row]] = await sql.query("SELECT * FROM technicaldocs WHERE id=?", [
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; const P = req.principal;
if (!P.is_superadmin && !P.inProject(row.project_id)) if (!P.is_superadmin && !P.inProject(row.project_id))
return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); return res.status(403).json({ error: "FORBIDDEN_PROJECT" });
await sql.query("DELETE FROM technicaldocs WHERE id=?", [id]); await sql.query("DELETE FROM technicaldocs WHERE id=?", [id]);
res.json({ ok: 1 }); res.json({ ok: 1 });
}); });
export default r; export default r;

View File

@@ -0,0 +1,55 @@
// FILE: backend/src/routes/users.js
import { Router } from "express";
import sql from "../db/index.js";
import { requirePerm } from "../middleware/requirePerm.js";
const r = Router();
// ME (ทุกคน)
r.get("/me", async (req, res) => {
const p = req.principal;
const [[u]] = await sql.query(
`SELECT user_id, username, email, first_name, last_name, org_id FROM users WHERE user_id=?`,
[p.user_id]
);
if (!u) return res.status(404).json({ error: "User not found" });
const [roles] = await sql.query(
`SELECT r.role_code, r.role_name, ur.org_id, ur.project_id
FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id
WHERE ur.user_id=?`,
[p.user_id]
);
res.json({
...u,
roles,
role_codes: roles.map((r) => r.role_code),
permissions: [...(p.permissions || [])],
project_ids: p.project_ids,
org_ids: p.org_ids,
is_superadmin: p.is_superadmin,
});
});
// USERS LIST (ORG scope) — admin.access
r.get(
"/",
requirePerm("admin.access", { orgParam: "org_id" }),
async (req, res) => {
const P = req.principal;
let rows = [];
if (P.is_superadmin) {
[rows] = await sql.query(
"SELECT user_id, username, email, org_id FROM users ORDER BY user_id DESC LIMIT 500"
);
} else if (P.org_ids?.length) {
const inSql = P.org_ids.map(() => "?").join(",");
[rows] = await sql.query(
`SELECT user_id, username, email, org_id FROM users WHERE org_id IN (${inSql}) ORDER BY user_id DESC LIMIT 500`,
P.org_ids
);
}
res.json(rows);
}
);
export default r;

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

View File

@@ -1,100 +1,100 @@
// FILE: backend/src/routes/view.js // FILE: backend/src/routes/view.js
// Saved Views: อ่านด้วย reports.view (GLOBAL); เขียนด้วย settings.manage // Saved Views: อ่านด้วย reports.view (GLOBAL); เขียนด้วย settings.manage
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";
const r = Router(); const r = Router();
// LIST (ทุกคนที่มี reports.view) // LIST (ทุกคนที่มี reports.view)
r.get("/", requirePerm("reports.view"), async (req, res) => { r.get("/", requirePerm("reports.view"), async (req, res) => {
const { project_id, shared = "1", q, limit = 50, offset = 0 } = req.query; const { project_id, shared = "1", q, limit = 50, offset = 0 } = req.query;
const p = req.principal; const p = req.principal;
const cond = []; const cond = [];
const params = []; const params = [];
// ให้เห็นของตัวเองเสมอ + shared // ให้เห็นของตัวเองเสมอ + shared
cond.push("(v.is_shared=1 OR v.owner_user_id=?)"); cond.push("(v.is_shared=1 OR v.owner_user_id=?)");
params.push(p.user_id); params.push(p.user_id);
if (project_id) { if (project_id) {
cond.push("v.project_id=?"); cond.push("v.project_id=?");
params.push(Number(project_id)); params.push(Number(project_id));
} }
if (q) { if (q) {
cond.push("v.name LIKE ?"); cond.push("v.name LIKE ?");
params.push(`%${q}%`); params.push(`%${q}%`);
} }
if (shared === "0") { if (shared === "0") {
cond.push("v.is_shared=0"); cond.push("v.is_shared=0");
} }
const where = `WHERE ${cond.join(" AND ")}`; const where = `WHERE ${cond.join(" AND ")}`;
const [rows] = await sql.query( const [rows] = await sql.query(
`SELECT v.* FROM saved_views v ${where} ORDER BY v.id DESC LIMIT ? OFFSET ?`, `SELECT v.* FROM saved_views v ${where} ORDER BY v.id DESC LIMIT ? OFFSET ?`,
[...params, Number(limit), Number(offset)] [...params, Number(limit), Number(offset)]
); );
res.json(rows); res.json(rows);
}); });
// GET // GET
r.get("/:id", requirePerm("reports.view"), async (req, res) => { r.get("/:id", requirePerm("reports.view"), 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" });
if ( if (
!( !(
row.is_shared || row.is_shared ||
row.owner_user_id === req.principal.user_id || row.owner_user_id === req.principal.user_id ||
req.principal.is_superadmin req.principal.is_superadmin
) )
) { ) {
return res.status(403).json({ error: "FORBIDDEN" }); return res.status(403).json({ error: "FORBIDDEN" });
} }
res.json(row); res.json(row);
}); });
// CREATE / UPDATE / DELETE (ต้องมี settings.manage) // CREATE / UPDATE / DELETE (ต้องมี settings.manage)
r.post("/", requirePerm("settings.manage"), async (req, res) => { r.post("/", requirePerm("settings.manage"), async (req, res) => {
const { const {
org_id, org_id,
project_id, project_id,
name, name,
payload_json, payload_json,
is_shared = 0, is_shared = 0,
} = req.body || {}; } = 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 ?? null, org_id ?? null,
project_id ?? null, project_id ?? null,
name ?? "", name ?? "",
JSON.stringify(payload_json ?? {}), JSON.stringify(payload_json ?? {}),
Number(is_shared) ? 1 : 0, Number(is_shared) ? 1 : 0,
req.principal.user_id, req.principal.user_id,
] ]
); );
res.status(201).json({ id: rs.insertId }); res.status(201).json({ id: rs.insertId });
}); });
r.put("/:id", requirePerm("settings.manage"), async (req, res) => { r.put("/:id", requirePerm("settings.manage"), 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 || {};
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 ?? null, name ?? null,
JSON.stringify(payload_json ?? {}), JSON.stringify(payload_json ?? {}),
Number(is_shared) ? 1 : 0, Number(is_shared) ? 1 : 0,
id, id,
] ]
); );
res.json({ ok: 1 }); res.json({ ok: 1 });
}); });
r.delete("/:id", requirePerm("settings.manage"), async (req, res) => { r.delete("/:id", requirePerm("settings.manage"), 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;

View File

@@ -0,0 +1,17 @@
// FILE: backend/src/utils/cookie.js
export const cookieOpts = (maxAgeMs) => {
const isProd = process.env.NODE_ENV === "production";
const opts = {
httpOnly: true,
secure: true, // หลัง Nginx/HTTPS
sameSite: "none", // ส่งข้าม subdomain ได้
path: "/",
maxAge: maxAgeMs,
};
if (process.env.COOKIE_DOMAIN) opts.domain = process.env.COOKIE_DOMAIN; // เช่น .np-dms.work
if (!isProd && process.env.ALLOW_INSECURE_COOKIE === "1") {
opts.secure = false;
opts.sameSite = "lax";
}
return opts;
};

31
backend/src/utils/jwt.js Normal file
View File

@@ -0,0 +1,31 @@
// FILE: backend/src/utils/jwt.js
import jwt from "jsonwebtoken";
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || "dev_access_secret";
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || "dev_refresh_secret";
export const ACCESS_TTL_MS = parseInt(
process.env.ACCESS_TTL_MS || `${15 * 60 * 1000}`,
10
); // 15 นาที
export const REFRESH_TTL_MS = parseInt(
process.env.REFRESH_TTL_MS || `${7 * 24 * 60 * 60 * 1000}`,
10
); // 7 วัน
export function signAccessToken(payload) {
return jwt.sign(payload, ACCESS_SECRET, {
expiresIn: Math.floor(ACCESS_TTL_MS / 1000),
});
}
export function signRefreshToken(payload) {
return jwt.sign(payload, REFRESH_SECRET, {
expiresIn: Math.floor(REFRESH_TTL_MS / 1000),
});
}
export function verifyAccessToken(token) {
return jwt.verify(token, ACCESS_SECRET, { clockTolerance: 10 }); // เผื่อเวลา QNAP คลาด
}
export function verifyRefreshToken(token) {
return jwt.verify(token, REFRESH_SECRET, { clockTolerance: 10 });
}

View File

@@ -1,107 +1,107 @@
// FILE: backend/src/utils/rbac.js // FILE: backend/src/utils/rbac.js
// 03.2 2) เพิ่มตัวช่วย RBAC (ใหม่) // 03.2 2) เพิ่มตัวช่วย RBAC (ใหม่)
// Role-Based Access Control (RBAC) utilities // Role-Based Access Control (RBAC) utilities
// - loadPrincipal(userId) to load user's roles, permissions, orgs, projects // - loadPrincipal(userId) to load user's roles, permissions, orgs, projects
// - canPerform(principal, permCode, {scope, orgId, projectId}) to check permission // - canPerform(principal, permCode, {scope, orgId, projectId}) to check permission
// - Uses raw SQL queries via db/index.js // - Uses raw SQL queries via db/index.js
// - Permissions can be global, org-scoped, or project-scoped // - Permissions can be global, org-scoped, or project-scoped
// - Admin roles have special handling for org/project scope // - Admin roles have special handling for org/project scope
// - SUPER_ADMIN bypasses all checks // - SUPER_ADMIN bypasses all checks
import sql from "../db/index.js"; import sql from "../db/index.js";
/** /**
* โหลด principal (บทบาท, องค์กร, โปรเจกต์) ให้ผู้ใช้ * โหลด principal (บทบาท, องค์กร, โปรเจกต์) ให้ผู้ใช้
*/ */
export async function loadPrincipal(userId) { export async function loadPrincipal(userId) {
const [rolesRows] = await sql.query( const [rolesRows] = await sql.query(
/*sql*/ ` /*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( const orgIds = new Set(
rolesRows.filter((r) => r.org_id).map((r) => r.org_id) rolesRows.filter((r) => r.org_id).map((r) => r.org_id)
); );
const projectIds = new Set( const projectIds = new Set(
rolesRows.filter((r) => r.project_id).map((r) => r.project_id) 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)) if (!perms.has(key))
perms.set(key, { orgIds: new Set(), projectIds: new Set() }); 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);
} }
return { return {
userId, userId,
roleCodes, // Set<role_code> roleCodes, // Set<role_code>
isSuperAdmin, // SUPER_ADMIN = true isSuperAdmin, // SUPER_ADMIN = true
orgIds, // องค์กรของผู้ใช้ (จาก mapping) orgIds, // องค์กรของผู้ใช้ (จาก mapping)
projectIds, // โปรเจกต์ของผู้ใช้ (จาก mapping) projectIds, // โปรเจกต์ของผู้ใช้ (จาก mapping)
perms, // Map<permission_code, {orgIds:Set, projectIds:Set}> perms, // Map<permission_code, {orgIds:Set, projectIds:Set}>
}; };
} }
/** /**
* ตรวจสิทธิ์ตามกติกา: * ตรวจสิทธิ์ตามกติกา:
* - SUPER_ADMIN: ผ่านทุกอย่าง (ข้าม org/project) * - SUPER_ADMIN: ผ่านทุกอย่าง (ข้าม org/project)
* - ADMIN: ผ่านได้ "ภายใน org ของตัวเอง" เท่านั้น * - ADMIN: ผ่านได้ "ภายใน org ของตัวเอง" เท่านั้น
* - อื่น ๆ: ต้องถือ permission_code และเข้า scope ที่ถูกต้อง * - อื่น ๆ: ต้องถือ permission_code และเข้า scope ที่ถูกต้อง
*/ */
export function canPerform( export function canPerform(
principal, principal,
permCode, permCode,
{ scope = "global", orgId = null, projectId = null } = {} { 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)) if (hasAdminRole && principal.orgIds.has(orgId))
return !!principal.perms.get(permCode); 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)) if (hasAdminRole && principal.projectIds.has(projectId))
return !!principal.perms.get(permCode); return !!principal.perms.get(permCode);
const entry = principal.perms.get(permCode); const entry = principal.perms.get(permCode);
return ( return (
!!entry && !!entry &&
(entry.projectIds.has(projectId) || entry.projectIds.size === 0) (entry.projectIds.has(projectId) || entry.projectIds.size === 0)
); );
} }
return false; return false;
} }

View File

@@ -1,98 +1,98 @@
// FILE: backend/src/utils/scope.js // FILE: backend/src/utils/scope.js
// 03.2 5) เพิ่ม utils/scope.js (ใหม่) // 03.2 5) เพิ่ม utils/scope.js (ใหม่)
// - ใช้ร่วมกับ requirePerm() และ loadPrincipal() // - ใช้ร่วมกับ requirePerm() และ loadPrincipal()
// - สำหรับสร้าง SQL WHERE clause ในการ list entities ตาม scope ของผู้ใช้ // - สำหรับสร้าง SQL WHERE clause ในการ list entities ตาม scope ของผู้ใช้
// Scope and permission utilities // Scope and permission utilities
// - Functions to build SQL WHERE clauses based on user principal and permissions // - Functions to build SQL WHERE clauses based on user principal and permissions
// - Used for filtering list queries according to user's // - Used for filtering list queries according to user's
// roles, permissions, and associated orgs/projects // roles, permissions, and associated orgs/projects
// - Works with rbac.js loadPrincipal() output // - Works with rbac.js loadPrincipal() output
// - Supports SUPER_ADMIN, ADMIN, and scoped permissions // - Supports SUPER_ADMIN, ADMIN, and scoped permissions
/** /**
* สร้าง WHERE fragment + params สำหรับ list ตาม principal * สร้าง WHERE fragment + params สำหรับ list ตาม principal
* - SUPER_ADMIN: ไม่จำกัด * - SUPER_ADMIN: ไม่จำกัด
* - ADMIN: จำกัดใน org/project ที่ตนสังกัด * - ADMIN: จำกัดใน org/project ที่ตนสังกัด
* - อื่น ๆ: จำกัดตาม permission scope ที่มี * - อื่น ๆ: จำกัดตาม permission scope ที่มี
* *
* @param {object} principal - จาก loadPrincipal() * @param {object} principal - จาก loadPrincipal()
* @param {object} opts * @param {object} opts
* tableAlias: ชื่อ alias ของตารางหลัก (เช่น 'c' สำหรับ correspondences) * tableAlias: ชื่อ alias ของตารางหลัก (เช่น 'c' สำหรับ correspondences)
* orgColumn: ระบุคอลัมน์ org_id (เช่น 'c.org_id') * orgColumn: ระบุคอลัมน์ org_id (เช่น 'c.org_id')
* projectColumn: ระบุคอลัมน์ project_id (เช่น 'c.project_id') * projectColumn: ระบุคอลัมน์ project_id (เช่น 'c.project_id')
* 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( export function buildScopeWhere(
principal, principal,
{ tableAlias, orgColumn, projectColumn, permCode, preferProject = false } { 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) {
return { return {
where: `${projectColumn} IN (:prjList)`, where: `${projectColumn} IN (:prjList)`,
params: { prjList }, params: { prjList },
}; };
} }
if (orgList.length > 0) { if (orgList.length > 0) {
return { return {
where: `${orgColumn} IN (:orgList)`, where: `${orgColumn} IN (:orgList)`,
params: { orgList }, params: { orgList },
}; };
} }
// ถ้าไม่มี 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];
if (preferProject && permPrj.length > 0) { if (preferProject && permPrj.length > 0) {
return { where: `${projectColumn} IN (:permPrj)`, params: { permPrj } }; return { where: `${projectColumn} IN (:permPrj)`, params: { permPrj } };
} }
if (permOrg.length > 0) { if (permOrg.length > 0) {
return { where: `${orgColumn} IN (:permOrg)`, params: { permOrg } }; return { where: `${orgColumn} IN (:permOrg)`, params: { permOrg } };
} }
// ถ้า 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( const [[row]] = await sql.query(
`SELECT org_id FROM ${mainTable} WHERE ${idColumn}=?`, `SELECT org_id FROM ${mainTable} WHERE ${idColumn}=?`,
[id] [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( const [[row]] = await sql.query(
`SELECT project_id FROM ${mainTable} WHERE ${idColumn}=?`, `SELECT project_id FROM ${mainTable} WHERE ${idColumn}=?`,
[id] [id]
); );
return row?.project_id ?? null; return row?.project_id ?? null;
}, },
}; };
} }

View File

@@ -0,0 +1,15 @@
import app from "../src/index.js"; // สมมติว่าคุณ export app จาก src/index.js
import request from "supertest";
// ปิด server หลังจากเทสเสร็จ
afterAll((done) => {
app.server.close(done);
});
describe("GET /health", () => {
it("should respond with 200 OK and a health message", async () => {
const response = await request(app).get("/health");
expect(response.statusCode).toBe(200);
expect(response.text).toContain("Backend is healthy");
});
});

View File

@@ -1,11 +0,0 @@
{
"folders": [
{
"path": "."
},
{
"path": "S:/Documents"
}
],
"settings": {}
}

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

@@ -16,8 +16,8 @@ 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
# docker build -t dms-backend:dev --target dev . # docker build -t dms-backend:dev --target dev .

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

@@ -1,4 +1,4 @@
# DMS Container v0_6_0 # DMS Container v0_7_0
# version: "3.8" # version: "3.8"
x-restart: &restart_policy x-restart: &restart_policy
restart: unless-stopped restart: unless-stopped
@@ -81,11 +81,16 @@ services:
DB_USER: "center" DB_USER: "center"
DB_PASSWORD: "Center#2025" DB_PASSWORD: "Center#2025"
DB_NAME: "dms" DB_NAME: "dms"
JWT_SECRET: "8b0df02e4aee9f9f79a4f2d8ba77b0b82c1ee3446b68cb0bae94ab54d60f8d9e" JWT_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca"
JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca"
JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c"
ACCESS_TTL_MS: "900000"
REFRESH_TTL_MS: "604800000"
JWT_EXPIRES_IN: "12h" JWT_EXPIRES_IN: "12h"
PASSWORD_SALT_ROUNDS: "10" PASSWORD_SALT_ROUNDS: "10"
FRONTEND_ORIGIN: "https://lcbp3.np-dms.work" 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"
@@ -130,10 +135,12 @@ services:
CHOKIDAR_USEPOLLING: "1" CHOKIDAR_USEPOLLING: "1"
WATCHPACK_POLLING: "true" WATCHPACK_POLLING: "true"
NEXT_PUBLIC_API_BASE: "https://lcbp3.np-dms.work" NEXT_PUBLIC_API_BASE: "https://lcbp3.np-dms.work"
NEXT_PUBLIC_AUTH_MODE: "cookie"
NEXT_PUBLIC_DEBUG_AUTH: "1" NEXT_PUBLIC_DEBUG_AUTH: "1"
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
JWT_ACCESS_SECRET: "change-this-access-secret" INTERNAL_API_BASE: "http://backend:3001"
JWT_REFRESH_SECRET: "change-this-refresh-secret" JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca"
JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c"
expose: expose:
- "3000" - "3000"
networks: [dmsnet] networks: [dmsnet]
@@ -148,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
@@ -260,12 +271,13 @@ services:
NODE_ENV: "production" NODE_ENV: "production"
N8N_PATH: "/n8n/" N8N_PATH: "/n8n/"
N8N_PUBLIC_URL: "https://n8n.np-dms.work/" N8N_PUBLIC_URL: "https://n8n.np-dms.work/"
WEBHOOK_URL: "https://ln8n.np-dms.work/" WEBHOOK_URL: "https://n8n.np-dms.work/"
N8N_EDITOR_BASE_URL: "https://n8n.np-dms.work/" N8N_EDITOR_BASE_URL: "https://n8n.np-dms.work/"
N8N_PROTOCOL: "https" N8N_PROTOCOL: "https"
N8N_HOST: "n8n.np-dms.work" N8N_HOST: "n8n.np-dms.work"
N8N_PORT: "5678" N8N_PORT: "5678"
N8N_PROXY_HOPS: "1" N8N_PROXY_HOPS: "1"
N8N_DIAGNOSTICS_ENABLED: "false"
N8N_SECURE_COOKIE: "true" N8N_SECURE_COOKIE: "true"
N8N_ENCRYPTION_KEY: "9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI" N8N_ENCRYPTION_KEY: "9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI"
N8N_BASIC_AUTH_ACTIVE: "true" N8N_BASIC_AUTH_ACTIVE: "true"
@@ -323,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:

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

@@ -24,6 +24,7 @@ services:
command: ["true"] command: ["true"]
# docker compose -f docker-frontend-build.yml build --no-cache # docker compose -f docker-frontend-build.yml build --no-cache
# docker compose -f docker-frontend-build.yml build --no-cache 2>&1 | tee frontend_build.log
# สร้าง package-lock.json # สร้าง package-lock.json
# cd frontend # cd frontend

View File

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

0
frontend/.editorconfig Normal file → Executable file
View File

0
frontend/.eslintrc.json Normal file → Executable file
View File

0
frontend/.prettierrc.json Normal file → Executable file
View File

4
frontend/Dockerfile Normal file → Executable file
View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1.6 # syntax=docker/dockerfile:1.6
############ Base ############ ############ Base ############
FROM node:24-alpine AS base FROM node:20-alpine AS base
WORKDIR /app WORKDIR /app
RUN apk add --no-cache bash curl tzdata \ RUN apk add --no-cache bash curl tzdata \
&& ln -snf /usr/share/zoneinfo/Asia/Bangkok /etc/localtime \ && ln -snf /usr/share/zoneinfo/Asia/Bangkok /etc/localtime \
@@ -66,6 +66,8 @@ RUN echo "=== Checking components ===" && \
echo "=== Checking .next permissions ===" && \ echo "=== Checking .next permissions ===" && \
ls -lad /app/.next ls -lad /app/.next
RUN npm ci --no-audit --no-fund --include=dev
RUN npm run build RUN npm run build
############ Prod runtime (optimized) ############ ############ Prod runtime (optimized) ############

View File

@@ -1,7 +1,7 @@
// Simple health endpoint for compose/ops // File: frontend/api/health/route.js
export async function GET() { export async function GET() {
return new Response(JSON.stringify({ status: 'ok', service: 'frontend', ts: Date.now() }), { return new Response(JSON.stringify({ status: 'ok', service: 'frontend', ts: Date.now() }), {
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
status: 200, status: 200,
}); });
} }

0
frontend/app/(auth)/layout.jsx Normal file → Executable file
View File

View File

@@ -1,57 +1,55 @@
// File: frontend/app/(auth)/login/page.jsx // File: frontend/app/(auth)/login/page.jsx
"use client"; "use client";
// ✅ ปรับให้ตรง backend: ใช้ Bearer token (ไม่ใช้ cookie)
// - เรียก POST /api/auth/login → รับ { token, refresh_token, user }
// - เก็บ token/refresh_token ใน localStorage (หรือ sessionStorage ถ้าไม่ติ๊กจำไว้)
// - ไม่ใช้ credentials: "include" อีกต่อไป
// - เอา RootLayout/metadata ออก เพราะไฟล์เพจเป็น client component
// - เพิ่มการอ่าน NEXT_PUBLIC_API_BASE และ error handling ให้ตรงกับ backend
// - เพิ่มโหมดดีบัก เปิดด้วย NEXT_PUBLIC_DEBUG_AUTH=1
import { useState, useMemo, Suspense } from "react"; import { useState, useMemo, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { import {
Card, Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || ""; const API_BASE = (process.env.NEXT_PUBLIC_API_BASE || "").replace(/\/$/, "");
const DEBUG = const DEBUG =
String(process.env.NEXT_PUBLIC_DEBUG_AUTH || "").trim() !== "" && String(process.env.NEXT_PUBLIC_DEBUG_AUTH || "").trim() !== "" &&
process.env.NEXT_PUBLIC_DEBUG_AUTH !== "0" && process.env.NEXT_PUBLIC_DEBUG_AUTH !== "0" &&
process.env.NEXT_PUBLIC_DEBUG_AUTH !== "false"; process.env.NEXT_PUBLIC_DEBUG_AUTH !== "false";
function dlog(...args) { function dlog(...args) {
if (DEBUG && typeof window !== "undefined") { if (DEBUG && typeof window !== "undefined") console.debug("[login]", ...args);
console.debug("[login]", ...args);
}
} }
function LoginForm() { function LoginForm() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const nextPath = useMemo( const nextPath = useMemo(() => searchParams.get("next") || "/dashboard", [searchParams]);
() => searchParams.get("next") || "/dashboard",
[searchParams]
);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPw, setShowPw] = useState(false); const [showPw, setShowPw] = useState(false);
const [remember, setRemember] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState(""); const [err, setErr] = useState("");
// Helper function to verify session is ready after login
async function verifySessionIsReady() {
const MAX_RETRIES = 5;
const RETRY_DELAY = 300; // ms
for (let i = 0; i < MAX_RETRIES; i++) {
const me = await fetch(`${API_BASE}/api/auth/me`, {
method: "GET",
credentials: "include",
cache: "no-store",
}).then(r => r.ok ? r.json() : null).catch(() => null);
if (me?.ok) return true;
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
}
return false;
}
async function onSubmit(e) { async function onSubmit(e) {
e.preventDefault(); e.preventDefault();
setErr(""); setErr("");
@@ -63,69 +61,48 @@ function LoginForm() {
try { try {
setSubmitting(true); setSubmitting(true);
dlog("API_BASE =", API_BASE || "(empty → relative path)"); dlog("nextPath =", nextPath);
// ── DEBUG: ค่าเบื้องต้น
dlog("API_BASE =", API_BASE || "(empty → จะเรียก path relative)");
dlog("nextPath =", nextPath);
dlog("remember =", remember);
dlog("payload =", { username: "[hidden]", password: "[hidden]" });
const res = await fetch(`${API_BASE}/api/auth/login`, { const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), credentials: "include", // << ใช้คุกกี้
cache: "no-store", cache: "no-store",
body: JSON.stringify({ username, password }),
}); });
dlog("response.status =", res.status); dlog("status =", res.status, "ctype =", res.headers.get("content-type"));
dlog("response.headers.content-type =", res.headers.get("content-type"));
let data = {}; let data = {};
try { try { data = await res.json(); } catch {}
data = await res.json();
} catch (e) {
dlog("response.json() error =", e);
}
dlog("response.body =", data);
if (!res.ok) { if (!res.ok) {
const msg = const msg =
data?.error === "INVALID_CREDENTIALS" data?.error === "INVALID_CREDENTIALS"
? "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง" ? "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง"
: data?.error || `เข้าสู่ระบบไม่สำเร็จ (HTTP ${res.status})`; : data?.error || `เข้าสู่ระบบไม่สำเร็จ (HTTP ${res.status})`;
dlog("login FAILED →", msg);
setErr(msg); setErr(msg);
return; return;
} }
if (!data?.token) { // ✅ ยืนยันว่าเซสชันพร้อมใช้งานก่อน (กัน redirect วน)
dlog("login FAILED → data.token not found"); // ✅ รอ session ให้พร้อมจริง (retry สูงสุด ~1.5s)
setErr("รูปแบบข้อมูลตอบกลับไม่ถูกต้อง (ไม่มี token)"); const ok = await verifySessionIsReady();
if (!ok) {
setErr("ล็อกอินสำเร็จ แต่ยังไม่เห็นเซสชันจากเซิร์ฟเวอร์ (ลองใหม่หรือตรวจคุกกี้)");
return; return;
} }
// ✅ เก็บ token ตามโหมดจำไว้/ไม่จำ // ✅ ใช้ hard navigation ให้ SSR เห็นคุกกี้แน่นอน
const storage = remember ? window.localStorage : window.sessionStorage; if (typeof window !== "undefined") {
storage.setItem("dms.token", data.token); window.location.href = nextPath || "/dashboard";
storage.setItem("dms.refresh_token", data.refresh_token); } else {
storage.setItem("dms.user", JSON.stringify(data.user || {})); router.replace(nextPath || "/dashboard");
dlog("token stored in", remember ? "localStorage" : "sessionStorage"); }
// (ออปชัน) เผยแพร่ event ให้แท็บอื่นทราบ
try {
window.dispatchEvent(
new StorageEvent("storage", { key: "dms.auth", newValue: "login" })
);
} catch {}
dlog("navigating →", nextPath);
router.replace(nextPath);
} catch (e) { } catch (e) {
dlog("exception =", e); dlog("exception =", e);
setErr("เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาลองใหม่"); setErr("เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาลองใหม่");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
dlog("done");
} }
} }
@@ -133,32 +110,22 @@ function LoginForm() {
<div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4"> <div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4">
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur"> <Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
<CardHeader className="space-y-1"> <CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-sky-800"> <CardTitle className="text-2xl font-bold text-sky-800">เขาสระบบ</CardTitle>
เขาสระบบ <CardDescription className="text-sky-700">Document Management System LCBP3</CardDescription>
</CardTitle>
<CardDescription className="text-sky-700">
Document Management System LCBP3
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{err ? ( {err ? (
<Alert className="mb-4"> <Alert className="mb-4"><AlertDescription>{err}</AlertDescription></Alert>
<AlertDescription>{err}</AlertDescription>
</Alert>
) : null} ) : null}
<form onSubmit={onSubmit} className="grid gap-4"> <form onSubmit={onSubmit} className="grid gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="username">อผใช</Label> <Label htmlFor="username">อผใช</Label>
<Input <Input
id="username" id="username" autoFocus autoComplete="username"
autoFocus value={username} onChange={(e) => setUsername(e.target.value)}
autoComplete="username" placeholder="เช่น superadmin" disabled={submitting}
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="เช่น superadmin"
disabled={submitting}
/> />
</div> </div>
@@ -166,59 +133,22 @@ function LoginForm() {
<Label htmlFor="password">รหสผาน</Label> <Label htmlFor="password">รหสผาน</Label>
<div className="relative"> <div className="relative">
<Input <Input
id="password" id="password" type={showPw ? "text" : "password"} autoComplete="current-password"
type={showPw ? "text" : "password"} value={password} onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password" placeholder="••••••••" disabled={submitting} className="pr-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={submitting}
className="pr-10"
/> />
<button <button
type="button" type="button" onClick={() => setShowPw((v) => !v)}
onClick={() => setShowPw((v) => !v)}
className="absolute inset-y-0 px-2 my-auto text-xs bg-white border rounded-md right-2 hover:bg-slate-50" className="absolute inset-y-0 px-2 my-auto text-xs bg-white border rounded-md right-2 hover:bg-slate-50"
aria-label={showPw ? "ซ่อนรหัสผ่าน" : "แสดงรหัสผ่าน"} aria-label={showPw ? "ซ่อนรหัสผ่าน" : "แสดงรหัสผ่าน"} disabled={submitting}
disabled={submitting}
> >
{showPw ? "Hide" : "Show"} {showPw ? "Hide" : "Show"}
</button> </button>
</div> </div>
</div> </div>
<div className="flex items-center justify-between pt-1"> <Button type="submit" disabled={submitting} className="mt-2 bg-sky-700 hover:bg-sky-800">
<label className="inline-flex items-center gap-2 text-sm text-slate-600"> {submitting ? <span className="inline-flex items-center gap-2"><Spinner /> กำลงเขาสระบบ</span> : "เข้าสู่ระบบ"}
<input
type="checkbox"
className="size-4 accent-sky-700"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
disabled={submitting}
/>
จดจำฉนไวในเครองน
</label>
<a
href="/forgot-password"
className="text-sm text-sky-700 hover:text-sky-900 hover:underline"
>
มรหสผาน?
</a>
</div>
<Button
type="submit"
disabled={submitting}
className="mt-2 bg-sky-700 hover:bg-sky-800"
>
{submitting ? (
<span className="inline-flex items-center gap-2">
<Spinner /> กำลงเขาสระบบ
</span>
) : (
"เข้าสู่ระบบ"
)}
</Button> </Button>
{DEBUG ? ( {DEBUG ? (
@@ -245,53 +175,42 @@ export default function LoginPage() {
); );
} }
/** Loading skeleton */
function LoginPageSkeleton() { function LoginPageSkeleton() {
return ( return (
<div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4"> <div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4">
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur"> <Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
<CardHeader className="space-y-1"> <CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-sky-800"> <CardTitle className="text-2xl font-bold text-sky-800">เขาสระบบ</CardTitle>
เขาสระบบ <CardDescription className="text-sky-700">Document Management System LCBP3</CardDescription>
</CardTitle>
<CardDescription className="text-sky-700">
Document Management System LCBP3
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-4 animate-pulse"> {/* ✅ ปรับปรุง Skeleton ให้สมจริงขึ้น */}
<div className="h-10 rounded bg-slate-200"></div> <div className="grid gap-4">
<div className="h-10 rounded bg-slate-200"></div> <div className="grid gap-2">
<div className="h-10 rounded bg-slate-200"></div> <div className="w-20 h-4 rounded bg-slate-200 animate-pulse"></div>
<div className="h-10 rounded bg-slate-200 animate-pulse"></div>
</div>
<div className="grid gap-2">
<div className="w-16 h-4 rounded bg-slate-200 animate-pulse"></div>
<div className="h-10 rounded bg-slate-200 animate-pulse"></div>
</div>
<div className="h-10 mt-2 rounded bg-slate-200 animate-pulse"></div>
</div> </div>
</CardContent> </CardContent>
{/* ✅ เพิ่ม Skeleton สำหรับ Footer */}
<CardFooter className="flex justify-center">
<div className="w-48 h-4 rounded bg-slate-200 animate-pulse"></div>
</CardFooter>
</Card> </Card>
</div> </div>
); );
} }
/** Spinner แบบไม่พึ่งไลบรารีเสริม */
function Spinner() { function Spinner() {
return ( return (
<svg <svg className="animate-spin size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
className="animate-spin size-4" <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
viewBox="0 0 24 24" <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
/>
</svg> </svg>
); );
} }

View File

@@ -0,0 +1,38 @@
// File: frontend/app/(protected)/admin/_components/confirm-delete-dialog.jsx
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
export function ConfirmDeleteDialog({
isOpen,
setIsOpen,
title,
description,
onConfirm,
isLoading,
}) {
return (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} disabled={isLoading} className="bg-red-600 hover:bg-red-700">
{isLoading ? 'Processing...' : 'Confirm'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,152 @@
// File: frontend/app/(protected)/admin/_components/role-form-dialog.jsx
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
export function RoleFormDialog({ role, allPermissions, isOpen, setIsOpen, onSuccess }) {
const [formData, setFormData] = useState({ name: '', description: '' });
const [selectedPermissions, setSelectedPermissions] = useState(new Set());
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const isEditMode = !!role;
useEffect(() => {
// Reset state ทุกครั้งที่ dialog เปิดขึ้นมาใหม่
if (isOpen) {
if (isEditMode) {
// โหมดแก้ไข: ตั้งค่าฟอร์มด้วยข้อมูล Role ที่มีอยู่
setFormData({ name: role.name, description: role.description || '' });
setSelectedPermissions(new Set(role.Permissions?.map(p => p.id) || []));
} else {
// โหมดสร้างใหม่: เคลียร์ฟอร์ม
setFormData({ name: '', description: '' });
setSelectedPermissions(new Set());
}
setError('');
}
}, [role, isOpen]); // ให้ re-run effect นี้เมื่อ role หรือ isOpen เปลี่ยน
const handleInputChange = (e) => {
const { id, value } = e.target;
setFormData((prev) => ({ ...prev, [id]: value }));
};
const handlePermissionChange = (permissionId) => {
setSelectedPermissions(prev => {
const newSet = new Set(prev);
if (newSet.has(permissionId)) {
newSet.delete(permissionId);
} else {
newSet.add(permissionId);
}
return newSet;
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
if (isEditMode) {
// โหมดแก้ไข: อัปเดต Permissions ของ Role ที่มีอยู่
await api.put(`/rbac/roles/${role.id}/permissions`, {
permissionIds: Array.from(selectedPermissions)
});
} else {
// โหมดสร้างใหม่: สร้าง Role ใหม่ก่อน
const newRoleRes = await api.post('/rbac/roles', formData);
// ถ้าสร้าง Role สำเร็จ และมีการเลือก Permission ไว้ ให้ทำการผูกสิทธิ์ทันที
if (newRoleRes.data && selectedPermissions.size > 0) {
await api.put(`/rbac/roles/${newRoleRes.data.id}/permissions`, {
permissionIds: Array.from(selectedPermissions)
});
}
}
onSuccess(); // บอกให้หน้าแม่ (roles/page.jsx) โหลดข้อมูลใหม่
setIsOpen(false); // ปิด Dialog
} catch (err) {
setError(err.response?.data?.message || 'An unexpected error occurred.');
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{isEditMode ? `Edit Permissions for: ${role.name}` : 'Create New Role'}</DialogTitle>
<DialogDescription>
{isEditMode ? 'Select the permissions for this role.' : 'Define a new role and its initial permissions.'}
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{/* แสดงฟอร์มสำหรับชื่อและคำอธิบายเฉพาะตอนสร้างใหม่ */}
{!isEditMode && (
<>
<div className="space-y-1">
<Label htmlFor="name">Role Name</Label>
<Input id="name" value={formData.name} onChange={handleInputChange} required />
</div>
<div className="space-y-1">
<Label htmlFor="description">Description</Label>
<Input id="description" value={formData.description} onChange={handleInputChange} />
</div>
</>
)}
<div>
<Label>Permissions</Label>
<ScrollArea className="h-60 w-full rounded-md border p-4 mt-1">
<div className="space-y-2">
{allPermissions.map(perm => (
<div key={perm.id} className="flex items-center space-x-2">
<Checkbox
id={`perm-${perm.id}`}
checked={selectedPermissions.has(perm.id)}
onCheckedChange={() => handlePermissionChange(perm.id)}
/>
<label htmlFor={`perm-${perm.id}`} className="text-sm font-medium leading-none cursor-pointer">
{perm.name}
</label>
</div>
))}
</div>
</ScrollArea>
</div>
</div>
{error && <p className="text-sm text-red-500 text-center pb-2">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsOpen(false)} disabled={isLoading}>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,284 @@
// File: frontend/app/(protected)/admin/users/_components/user-form-dialog.jsx
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Trash2 } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
export function UserFormDialog({ user, isOpen, setIsOpen, onSuccess }) {
// State for form fields
const [formData, setFormData] = useState({});
const [selectedSystemRoles, setSelectedSystemRoles] = useState(new Set());
// State for project role assignments
const [projectRoles, setProjectRoles] = useState([]);
const [selectedProjectId, setSelectedProjectId] = useState('');
const [selectedRoleId, setSelectedRoleId] = useState('');
// State for prerequisite data (fetched once)
const [allRoles, setAllRoles] = useState([]);
const [allProjects, setAllProjects] = useState([]);
// UI State
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const isEditMode = !!user;
// Effect to fetch prerequisite data (all roles and projects) when dialog opens
useEffect(() => {
const fetchPrerequisites = async () => {
try {
const [rolesRes, projectsRes] = await Promise.all([
api.get('/rbac/roles'),
api.get('/projects'),
]);
setAllRoles(rolesRes.data);
setAllProjects(projectsRes.data);
} catch (err) {
console.error('Failed to fetch prerequisites', err);
setError('Could not load required data (roles, projects).');
}
};
if (isOpen) {
fetchPrerequisites();
}
}, [isOpen]);
// Effect to set up the form when the user prop changes (for editing) or when opening for creation
useEffect(() => {
const setupForm = async () => {
if (isEditMode) {
// Edit mode: populate form with user data
setFormData({
username: user.username,
email: user.email,
first_name: user.first_name || '',
last_name: user.last_name || '',
is_active: user.is_active,
});
setSelectedSystemRoles(new Set(user.Roles?.map(role => role.id) || []));
// Fetch this user's specific project roles
try {
const res = await api.get(`/rbac/user-project-roles?userId=${user.id}`);
setProjectRoles(res.data);
} catch (err) {
console.error("Failed to fetch user's project roles", err);
setProjectRoles([]);
}
} else {
// Create mode: reset all fields
setFormData({ username: '', email: '', password: '', first_name: '', last_name: '', is_active: true });
setSelectedSystemRoles(new Set());
setProjectRoles([]);
}
// Reset local state
setError('');
setSelectedProjectId('');
setSelectedRoleId('');
};
if (isOpen) {
setupForm();
}
}, [user, isOpen]);
const handleInputChange = (e) => {
const { id, value } = e.target;
setFormData((prev) => ({ ...prev, [id]: value }));
};
const handleSystemRoleChange = (roleId) => {
setSelectedSystemRoles(prev => {
const newSet = new Set(prev);
if (newSet.has(roleId)) newSet.delete(roleId);
else newSet.add(roleId);
return newSet;
});
};
const handleAddProjectRole = async () => {
if (!selectedProjectId || !selectedRoleId) {
setError("Please select both a project and a role.");
return;
}
setIsLoading(true);
setError('');
try {
await api.post('/rbac/user-project-roles', {
userId: user.id,
projectId: selectedProjectId,
roleId: selectedRoleId
});
// Refresh the list after adding
const res = await api.get(`/rbac/user-project-roles?userId=${user.id}`);
setProjectRoles(res.data);
setSelectedProjectId('');
setSelectedRoleId('');
} catch(err) {
setError(err.response?.data?.message || 'Failed to add project role.');
} finally {
setIsLoading(false);
}
};
const handleRemoveProjectRole = async (assignment) => {
setIsLoading(true);
setError('');
try {
await api.delete('/rbac/user-project-roles', {
data: { userId: user.id, projectId: assignment.project_id, roleId: assignment.role_id }
});
// Refresh list visually without another API call
setProjectRoles(prev => prev.filter(p => p.id !== assignment.id));
} catch(err) {
setError(err.response?.data?.message || 'Failed to remove project role.');
} finally {
setIsLoading(false);
}
};
const handleSaveUserDetails = async (e) => {
e.preventDefault();
setIsLoading(true);
setError('');
const payload = { ...formData, roles: Array.from(selectedSystemRoles) };
try {
if (isEditMode) {
await api.put(`/users/${user.id}`, payload);
} else {
await api.post('/users', payload);
}
onSuccess(); // Tell the parent page to refresh its data
setIsOpen(false); // Close the dialog
} catch (err) {
setError(err.response?.data?.message || 'An unexpected error occurred.');
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-3xl">
<form onSubmit={handleSaveUserDetails}>
<DialogHeader>
<DialogTitle>{isEditMode ? `Edit User: ${user.username}` : 'Create New User'}</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[70vh] -mr-6 pr-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4 p-4">
{/* Section 1: User Details & System Roles */}
<div className="space-y-4 border-r-0 md:border-r md:pr-4">
<h3 className="font-semibold border-b pb-2">User Details & System Roles</h3>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input id="username" value={formData.username || ''} onChange={handleInputChange} required disabled={isEditMode} />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={formData.email || ''} onChange={handleInputChange} required />
</div>
{!isEditMode && (
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" value={formData.password || ''} onChange={handleInputChange} required />
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first_name">First Name</Label>
<Input id="first_name" value={formData.first_name || ''} onChange={handleInputChange} />
</div>
<div className="space-y-2">
<Label htmlFor="last_name">Last Name</Label>
<Input id="last_name" value={formData.last_name || ''} onChange={handleInputChange} />
</div>
</div>
<div className="space-y-2">
<Label>System Roles</Label>
<ScrollArea className="h-24 w-full rounded-md border p-2">
{allRoles.map(role => (
<div key={role.id} className="flex items-center space-x-2">
<Checkbox id={`role-${role.id}`} checked={selectedSystemRoles.has(role.id)} onCheckedChange={() => handleSystemRoleChange(role.id)} />
<label htmlFor={`role-${role.id}`} className="text-sm font-medium leading-none cursor-pointer">{role.name}</label>
</div>
))}
</ScrollArea>
</div>
<div className="flex items-center space-x-2 pt-2">
<Switch id="is_active" checked={formData.is_active || false} onCheckedChange={(checked) => setFormData(prev => ({...prev, is_active: checked}))} />
<Label htmlFor="is_active">User is Active</Label>
</div>
</div>
{/* Section 2: Project Role Assignments */}
<div className="space-y-4">
<h3 className="font-semibold border-b pb-2">Project Role Assignments</h3>
{isEditMode ? (
<>
<div className="p-4 border rounded-lg bg-muted/50 space-y-3">
<p className="text-sm font-medium">Assign New Project Role</p>
<div className="grid grid-cols-2 gap-2">
<Select onValueChange={setSelectedProjectId} value={selectedProjectId}>
<SelectTrigger><SelectValue placeholder="Select Project" /></SelectTrigger>
<SelectContent>{allProjects.map(p => <SelectItem key={p.id} value={String(p.id)}>{p.name}</SelectItem>)}</SelectContent>
</Select>
<Select onValueChange={setSelectedRoleId} value={selectedRoleId}>
<SelectTrigger><SelectValue placeholder="Select Role" /></SelectTrigger>
<SelectContent>{allRoles.map(r => <SelectItem key={r.id} value={String(r.id)}>{r.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<Button type="button" onClick={handleAddProjectRole} disabled={isLoading || !selectedProjectId || !selectedRoleId} size="sm" className="w-full">
{isLoading ? 'Adding...' : 'Add Project Role'}
</Button>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Current Assignments</p>
<ScrollArea className="h-48 w-full rounded-md border p-1">
<div className="space-y-1 p-1">
{projectRoles.length > 0 ? projectRoles.map(pr => (
<div key={pr.id} className="flex justify-between items-center text-sm p-2 border rounded-md">
<div>
<span className="font-semibold">{pr.Project.name}</span>
<span className="text-muted-foreground"> as </span>
<span>{pr.Role.name}</span>
</div>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleRemoveProjectRole(pr)} disabled={isLoading}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
)) : <p className="text-sm text-muted-foreground italic text-center py-2">No project assignments.</p>}
</div>
</ScrollArea>
</div>
</>
) : <p className="text-sm text-muted-foreground italic text-center py-4">Save the user first to assign project roles.</p>}
</div>
</div>
</ScrollArea>
{error && <p className="text-sm text-red-500 text-center pt-2">{error}</p>}
<DialogFooter className="pt-4 border-t">
<Button type="button" variant="outline" onClick={() => setIsOpen(false)} disabled={isLoading}>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save User Details'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,43 @@
// File: frontend/app/(protected)/admin/layout.jsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Users, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils'; // ตรวจสอบว่า import cn มาจากที่ถูกต้อง
export default function AdminLayout({ children }) {
const pathname = usePathname();
const navLinks = [
{ href: '/admin/users', label: 'User Management', icon: Users },
{ href: '/admin/roles', label: 'Role & Permission', icon: ShieldCheck },
];
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-3xl font-bold">Admin Settings</h1>
<p className="text-muted-foreground">Manage users, roles, and system permissions.</p>
</div>
<div className="flex border-b">
{navLinks.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className={cn(
'flex items-center gap-2 px-4 py-2 -mb-px border-b-2 text-sm font-medium transition-colors',
pathname === href
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
)}
>
<Icon className="h-4 w-4" />
{label}
</Link>
))}
</div>
<div>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
// File: frontend/app/(protected)/admin/roles/page.jsx
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ShieldCheck, PlusCircle } from 'lucide-react';
// Import Dialog component ที่เราเพิ่งสร้าง
import { RoleFormDialog } from '../_components/role-form-dialog';
export default function RolesPage() {
const [roles, setRoles] = useState([]);
const [allPermissions, setAllPermissions] = useState([]);
const [loading, setLoading] = useState(true);
// State สำหรับควบคุม Dialog
const [isFormOpen, setIsFormOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState(null);
const fetchData = async () => {
try {
setLoading(true);
const [rolesRes, permsRes] = await Promise.all([
api.get('/rbac/roles'),
api.get('/rbac/permissions'),
]);
setRoles(rolesRes.data);
setAllPermissions(permsRes.data);
} catch (error) {
console.error("Failed to fetch RBAC data", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const handleCreate = () => {
setSelectedRole(null); // ไม่มี Role ที่เลือก = สร้างใหม่
setIsFormOpen(true);
};
const handleEdit = (role) => {
setSelectedRole(role);
setIsFormOpen(true);
};
if (loading) return <div>Loading role settings...</div>;
return (
<>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold">Roles & Permissions</h2>
<Button onClick={handleCreate}>
<PlusCircle className="w-4 h-4 mr-2" /> Add Role
</Button>
</div>
{roles.map(role => (
<Card key={role.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="text-primary" />
{role.name}
</CardTitle>
<CardDescription>{role.description || 'No description'}</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={() => handleEdit(role)}>
Edit Permissions
</Button>
</div>
</CardHeader>
<CardContent>
<p className="mb-2 text-sm font-medium">Assigned Permissions:</p>
<div className="flex flex-wrap gap-2">
{role.Permissions.length > 0 ? (
role.Permissions.map(perm => (
<Badge key={perm.id} variant="secondary">{perm.name}</Badge>
))
) : (
<p className="text-sm text-muted-foreground">No permissions assigned.</p>
)}
</div>
</CardContent>
</Card>
))}
</div>
<RoleFormDialog
isOpen={isFormOpen}
setIsOpen={setIsFormOpen}
role={selectedRole}
allPermissions={allPermissions}
onSuccess={fetchData}
/>
</>
);
}

View File

@@ -0,0 +1,161 @@
// File: frontend/app/(protected)/admin/users/page.jsx
'use client';
import { useState, useEffect } from 'react';
import { PlusCircle, MoreHorizontal } from 'lucide-react';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
// Import components ที่เราเพิ่งสร้าง
import { UserFormDialog } from '../_components/user-form-dialog';
import { ConfirmDeleteDialog } from '../_components/confirm-delete-dialog';
export default function UsersPage() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
// State สำหรับควบคุม Dialog ทั้งหมด
const [isFormOpen, setIsFormOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Function สำหรับดึงข้อมูลใหม่
const fetchUsers = async () => {
try {
setLoading(true);
const res = await api.get('/users');
setUsers(res.data);
} catch (error) {
console.error("Failed to fetch users", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
// Handlers สำหรับเปิด Dialog
const handleCreate = () => {
setSelectedUser(null);
setIsFormOpen(true);
};
const handleEdit = (user) => {
setSelectedUser(user);
setIsFormOpen(true);
};
const handleDelete = (user) => {
setSelectedUser(user);
setIsDeleteOpen(true);
};
// Function ที่จะทำงานเมื่อยืนยันการลบ
const confirmDeactivate = async () => {
if (!selectedUser) return;
setIsSubmitting(true);
try {
await api.delete(`/users/${selectedUser.id}`);
fetchUsers(); // Refresh ข้อมูล
setIsDeleteOpen(false);
} catch (error) {
console.error("Failed to deactivate user", error);
// ควรมี Alert แจ้งเตือน
} finally {
setIsSubmitting(false);
}
};
return (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>User Accounts</CardTitle>
<CardDescription>Manage all user accounts and their roles.</CardDescription>
</div>
<Button onClick={handleCreate}>
<PlusCircle className="w-4 h-4 mr-2" /> Add User
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Username</TableHead>
<TableHead>Email</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Status</TableHead>
<TableHead><span className="sr-only">Actions</span></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={5} className="text-center">Loading...</TableCell></TableRow>
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{user.Roles?.map(role => <Badge key={role.id} variant="secondary">{role.name}</Badge>)}
</div>
</TableCell>
<TableCell>
<Badge variant={user.is_active ? 'default' : 'destructive'}>
{user.is_active ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="w-8 h-8 p-0"><MoreHorizontal className="w-4 h-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => handleEdit(user)}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(user)} className="text-red-500">
Deactivate
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Render Dialogs ที่นี่ (มันจะไม่แสดงผลจนกว่า state จะเป็น true) */}
<UserFormDialog
user={selectedUser}
isOpen={isFormOpen}
setIsOpen={setIsFormOpen}
onSuccess={fetchUsers}
/>
<ConfirmDeleteDialog
isOpen={isDeleteOpen}
setIsOpen={setIsDeleteOpen}
isLoading={isSubmitting}
title="Are you sure?"
description={`This will deactivate the user "${selectedUser?.username}". They will no longer be able to log in.`}
onConfirm={confirmDeactivate}
/>
</>
);
}

View File

@@ -1,3 +1,3 @@
export default function Page(){ export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Contracts & Volumes โครงขอม/กเอกสาร</div>; return <div className="rounded-2xl p-5 bg-white">Contracts & Volumes โครงขอม/กเอกสาร</div>;
} }

View File

@@ -1,3 +1,3 @@
export default function Page(){ export default function Page(){
return <div className="rounded-2xl p-5 bg-white">ฟอรมบนทกหนงสอสอสาร</div>; return <div className="rounded-2xl p-5 bg-white">ฟอรมบนทกหนงสอสอสาร</div>;
} }

View File

@@ -1,3 +1,3 @@
export default function Page(){ export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Correspondences list/table</div>; return <div className="rounded-2xl p-5 bg-white">Correspondences list/table</div>;
} }

View File

@@ -0,0 +1,977 @@
// frontend/app//(protected)/dashboard/page.jsx
"use client";
import React from "react";
import Link from "next/link";
import { motion } from "framer-motion";
import {
LayoutDashboard,
FileText,
Files,
Send,
Layers,
Users,
Settings,
Activity,
Search,
ChevronRight,
ShieldCheck,
Workflow,
Database,
Mail,
Server,
Shield,
BookOpen,
PanelLeft,
PanelRight,
ChevronDown,
Plus,
Filter,
Eye,
EyeOff,
SlidersHorizontal,
Columns3,
X,
ExternalLink,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Switch } from "@/components/ui/switch";
import { API_BASE } from "@/lib/api";
const sea = {
light: "#E6F7FB",
light2: "#F3FBFD",
mid: "#2A7F98",
dark: "#0D5C75",
textDark: "#0E2932",
};
const can = (user, perm) => new Set(user?.permissions || []).has(perm);
const Tag = ({ children }) => (
<Badge
className="px-3 py-1 text-xs border-0 rounded-full"
style={{ background: sea.light, color: sea.dark }}
>
{children}
</Badge>
);
const SidebarItem = ({ label, icon: Icon, active = false, badge }) => (
<button
className={`group w-full flex items-center gap-3 rounded-2xl px-4 py-3 text-left transition-all border ${
active ? "bg-white/70" : "bg-white/30 hover:bg-white/60"
}`}
style={{ borderColor: "#ffffff40", color: sea.textDark }}
>
<Icon className="w-5 h-5" />
<span className="font-medium grow">{label}</span>
{badge ? (
<span
className="text-xs rounded-full px-2 py-0.5"
style={{ background: sea.light, color: sea.dark }}
>
{badge}
</span>
) : null}
<ChevronRight className="w-4 h-4 transition-opacity opacity-0 group-hover:opacity-100" />
</button>
);
const KPI = ({ label, value, icon: Icon, onClick }) => (
<Card
onClick={onClick}
className="transition border-0 shadow-sm cursor-pointer rounded-2xl hover:shadow"
style={{ background: "white" }}
>
<CardContent className="p-5">
<div className="flex items-start justify-between">
<span className="text-sm opacity-70">{label}</span>
<div className="p-2 rounded-xl" style={{ background: sea.light }}>
<Icon className="w-5 h-5" style={{ color: sea.dark }} />
</div>
</div>
<div className="mt-3 text-3xl font-bold" style={{ color: sea.textDark }}>
{value}
</div>
<div className="mt-2">
<Progress value={Math.min(100, (value / 400) * 100)} />
</div>
</CardContent>
</Card>
);
function PreviewDrawer({ open, onClose, children }) {
return (
<div
className={`fixed top-0 right-0 h-full w-full sm:w-[420px] bg-white shadow-2xl transition-transform z-50 ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="flex items-center justify-between p-4 border-b">
<div className="font-medium">รายละเอยด</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
<div className="p-4 overflow-auto h-[calc(100%-56px)]">{children}</div>
</div>
);
}
export default function DashboardPage() {
const [user, setUser] = React.useState(null);
const [sidebarOpen, setSidebarOpen] = React.useState(true);
const [densityCompact, setDensityCompact] = React.useState(false);
const [showCols, setShowCols] = React.useState({
type: true,
id: true,
title: true,
status: true,
due: true,
owner: true,
actions: true,
});
const [previewOpen, setPreviewOpen] = React.useState(false);
const [filters, setFilters] = React.useState({
type: "All",
status: "All",
overdue: false,
});
const [activeQuery, setActiveQuery] = React.useState({});
React.useEffect(() => {
fetch(`${API_BASE}/auth/me`, { credentials: "include" })
.then((r) => (r.ok ? r.json() : null))
.then((data) => setUser(data?.user || null))
.catch(() => setUser(null));
}, []);
const quickLinks = [
{
label: "สร้าง RFA",
icon: FileText,
perm: "rfa:create",
href: "/rfas/new",
},
{
label: "อัปโหลด Drawing",
icon: Layers,
perm: "drawing:upload",
href: "/drawings/upload",
},
{
label: "สร้าง Transmittal",
icon: Send,
perm: "transmittal:create",
href: "/transmittals/new",
},
{
label: "บันทึกหนังสือสื่อสาร",
icon: Mail,
perm: "correspondence:create",
href: "/correspondences/new",
},
];
const nav = [
{ label: "แดชบอร์ด", icon: LayoutDashboard },
{ label: "Drawings", icon: Layers },
{ label: "RFAs", icon: FileText },
{ label: "Transmittals", icon: Send },
{ label: "Contracts & Volumes", icon: BookOpen },
{ label: "Correspondences", icon: Files },
{ label: "ผู้ใช้/บทบาท", icon: Users, perm: "users:manage" },
{ label: "Reports", icon: Activity },
{ label: "Workflow (n8n)", icon: Workflow, perm: "workflow:view" },
{ label: "Health", icon: Server, perm: "health:view" },
{ label: "Admin", icon: Settings, perm: "admin:view" },
];
const kpis = [
{
key: "rfa-pending",
label: "RFAs รออนุมัติ",
value: 12,
icon: FileText,
query: { type: "RFA", status: "pending" },
},
{
key: "drawings",
label: "แบบ (Drawings) ล่าสุด",
value: 326,
icon: Layers,
query: { type: "Drawing" },
},
{
key: "trans-month",
label: "Transmittals เดือนนี้",
value: 18,
icon: Send,
query: { type: "Transmittal", month: "current" },
},
{
key: "overdue",
label: "เกินกำหนด (Overdue)",
value: 5,
icon: Activity,
query: { overdue: true },
},
];
const recent = [
{
type: "RFA",
code: "RFA-LCP3-0012",
title: "ปรับปรุงรายละเอียดเสาเข็มท่าเรือ",
who: "สุรเชษฐ์ (Editor)",
when: "เมื่อวาน 16:40",
},
{
type: "Drawing",
code: "DWG-C-210A-Rev.3",
title: "แปลนโครงสร้างท่าเรือส่วนที่ 2",
who: "วรวิชญ์ (Admin)",
when: "วันนี้ 09:15",
},
{
type: "Transmittal",
code: "TR-2025-0916-04",
title: "ส่งแบบ Rebar Shop Drawing ชุด A",
who: "Supansa (Viewer)",
when: "16 ก.ย. 2025",
},
{
type: "Correspondence",
code: "CRSP-58",
title: "แจ้งเลื่อนประชุมตรวจแบบ",
who: "Kitti (Editor)",
when: "15 ก.ย. 2025",
},
];
const items = [
{
t: "RFA",
id: "RFA-LCP3-0013",
title: "ยืนยันรายละเอียดท่อระบายน้ำ",
status: "Pending",
due: "20 ก.ย. 2025",
owner: "คุณแดง",
},
{
t: "Drawing",
id: "DWG-S-115-Rev.1",
title: "Section เสาเข็มพื้นที่ส่วนที่ 1",
status: "Review",
due: "19 ก.ย. 2025",
owner: "วิทยา",
},
{
t: "Transmittal",
id: "TR-2025-0915-03",
title: "ส่งแบบโครงสร้างท่าเรือ ชุด B",
status: "Sent",
due: "—",
owner: "สุธิดา",
},
];
const visibleItems = items.filter((r) => {
if (filters.type !== "All" && r.t !== filters.type) return false;
if (filters.status !== "All" && r.status !== filters.status) return false;
if (filters.overdue && r.due === "—") return false;
return true;
});
const onKpiClick = (q) => {
setActiveQuery(q);
if (q?.type) setFilters((f) => ({ ...f, type: q.type }));
if (q?.overdue) setFilters((f) => ({ ...f, overdue: true }));
};
return (
<TooltipProvider>
<div
className="min-h-screen"
style={{
background: `linear-gradient(180deg, ${sea.light2} 0%, ${sea.light} 100%)`,
}}
>
<header
className="sticky top-0 z-40 border-b backdrop-blur-md"
style={{
borderColor: "#ffffff66",
background: "rgba(230,247,251,0.7)",
}}
>
<div className="flex items-center gap-3 px-4 py-2 mx-auto max-w-7xl">
<button
className="flex items-center justify-center shadow-sm h-9 w-9 rounded-2xl"
style={{ background: sea.dark }}
onClick={() => setSidebarOpen((v) => !v)}
aria-label={sidebarOpen ? "ซ่อนแถบด้านข้าง" : "แสดงแถบด้านข้าง"}
>
{sidebarOpen ? (
<PanelLeft className="w-5 h-5 text-white" />
) : (
<PanelRight className="w-5 h-5 text-white" />
)}
</button>
<div>
<div className="text-xs opacity-70">
Document Management System
</div>
<div className="font-semibold" style={{ color: sea.textDark }}>
โครงการพฒนาทาเรอแหลมฉบ ระยะท 3 วนท 14
</div>
</div>
<Tag>Phase 3</Tag>
<Tag>Port Infrastructure</Tag>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="flex items-center gap-2 ml-auto rounded-2xl btn-sea">
System <ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-56">
<DropdownMenuLabel>ระบบ</DropdownMenuLabel>
{can(user, "admin:view") && (
<DropdownMenuItem>
<Settings className="w-4 h-4 mr-2" /> Admin
</DropdownMenuItem>
)}
{can(user, "users:manage") && (
<DropdownMenuItem>
<Users className="w-4 h-4 mr-2" /> ใช/บทบาท
</DropdownMenuItem>
)}
{can(user, "health:view") && (
<DropdownMenuItem asChild>
<a href="/health" className="flex items-center w-full">
<Server className="w-4 h-4 mr-2" /> Health{" "}
<ExternalLink className="w-3 h-3 ml-auto" />
</a>
</DropdownMenuItem>
)}
{can(user, "workflow:view") && (
<DropdownMenuItem asChild>
<a href="/workflow" className="flex items-center w-full">
<Workflow className="w-4 h-4 mr-2" /> Workflow (n8n){" "}
<ExternalLink className="w-3 h-3 ml-auto" />
</a>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="ml-2 rounded-2xl btn-sea">
<Plus className="w-4 h-4 mr-1" /> New
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{quickLinks.map(({ label, icon: Icon, perm, href }) =>
can(user, perm) ? (
<DropdownMenuItem key={label} asChild>
<Link href={href} className="flex items-center">
<Icon className="w-4 h-4 mr-2" />
{label}
</Link>
</DropdownMenuItem>
) : (
<Tooltip key={label}>
<TooltipTrigger asChild>
<div className="px-2 py-1.5 text-sm opacity-40 cursor-not-allowed flex items-center">
<Icon className="w-4 h-4 mr-2" />
{label}
</div>
</TooltipTrigger>
<TooltipContent>
ไมทธใชงาน ({perm})
</TooltipContent>
</Tooltip>
)
)}
<DropdownMenuSeparator />
<DropdownMenuItem>
<Layers className="w-4 h-4 mr-2" /> Import / Bulk upload
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<div className="grid grid-cols-12 gap-6 px-4 py-6 mx-auto max-w-7xl">
{sidebarOpen && (
<aside className="col-span-12 lg:col-span-3 xl:col-span-3">
<div
className="p-4 border rounded-3xl"
style={{
background: "rgba(255,255,255,0.7)",
borderColor: "#ffffff66",
}}
>
<div className="flex items-center gap-2 mb-3">
<ShieldCheck
className="w-5 h-5"
style={{ color: sea.dark }}
/>
<div className="text-sm">
RBAC:{" "}
<span className="font-medium">{user?.role || "—"}</span>
</div>
</div>
<div className="relative mb-3">
<Search className="absolute w-4 h-4 -translate-y-1/2 left-3 top-1/2 opacity-70" />
<Input
placeholder="ค้นหา RFA / Drawing / Transmittal / Code…"
className="bg-white border-0 pl-9 rounded-2xl"
/>
</div>
<div
className="p-3 mb-3 border rounded-2xl"
style={{ borderColor: "#eef6f8", background: "#ffffffaa" }}
>
<div className="mb-2 text-xs font-medium">วกรอง</div>
<div className="grid grid-cols-2 gap-2">
<select
className="p-2 text-sm border rounded-xl"
value={filters.type}
onChange={(e) =>
setFilters((f) => ({ ...f, type: e.target.value }))
}
>
<option>All</option>
<option>RFA</option>
<option>Drawing</option>
<option>Transmittal</option>
<option>Correspondence</option>
</select>
<select
className="p-2 text-sm border rounded-xl"
value={filters.status}
onChange={(e) =>
setFilters((f) => ({ ...f, status: e.target.value }))
}
>
<option>All</option>
<option>Pending</option>
<option>Review</option>
<option>Sent</option>
</select>
<label className="flex items-center col-span-2 gap-2 text-sm">
<Switch
checked={filters.overdue}
onCheckedChange={(v) =>
setFilters((f) => ({ ...f, overdue: v }))
}
/>{" "}
แสดงเฉพาะ Overdue
</label>
</div>
<div className="flex gap-2 mt-2">
<Button
size="sm"
variant="outline"
className="rounded-xl"
style={{ borderColor: sea.mid, color: sea.dark }}
>
<Filter className="w-4 h-4 mr-1" />
Apply
</Button>
<Button
size="sm"
variant="ghost"
className="rounded-xl"
onClick={() =>
setFilters({
type: "All",
status: "All",
overdue: false,
})
}
>
Reset
</Button>
</div>
</div>
<div className="space-y-2">
{nav
.filter((item) => !item.perm || can(user, item.perm))
.map((n, i) => (
<SidebarItem
key={n.label}
label={n.label}
icon={n.icon}
active={i === 0}
badge={n.label === "RFAs" ? 12 : undefined}
/>
))}
</div>
<div className="flex items-center gap-2 mt-5 text-xs opacity-70">
<Database className="w-4 h-4" /> dms_db MariaDB 10.11
</div>
</div>
</aside>
)}
<main
className={`col-span-12 ${
sidebarOpen ? "lg:col-span-9 xl:col-span-9" : ""
} space-y-6`}
>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05, duration: 0.4 }}
>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{kpis.map((k) => (
<KPI key={k.key} {...k} onClick={() => onKpiClick(k.query)} />
))}
</div>
</motion.div>
<div className="flex items-center justify-between">
<div className="text-sm opacity-70">
ผลลพธจากตวกรอง: {filters.type}/{filters.status}
{filters.overdue ? " • Overdue" : ""}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="rounded-xl"
style={{ borderColor: sea.mid, color: sea.dark }}
onClick={() => setDensityCompact((v) => !v)}
>
<SlidersHorizontal className="w-4 h-4 mr-1" /> Density:{" "}
{densityCompact ? "Compact" : "Comfort"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="rounded-xl"
style={{ borderColor: sea.mid, color: sea.dark }}
>
<Columns3 className="w-4 h-4 mr-1" /> Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{Object.keys(showCols).map((key) => (
<DropdownMenuItem
key={key}
onClick={() =>
setShowCols((s) => ({ ...s, [key]: !s[key] }))
}
>
{showCols[key] ? (
<Eye className="w-4 h-4 mr-2" />
) : (
<EyeOff className="w-4 h-4 mr-2" />
)}
{key}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Card className="border-0 rounded-2xl">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table
className={`min-w-full text-sm ${
densityCompact ? "[&_*]:py-1" : ""
}`}
>
<thead
className="sticky top-[56px] z-10"
style={{
background: "white",
borderBottom: "1px solid #efefef",
}}
>
<tr className="text-left">
{showCols.type && <th className="px-3 py-2">ประเภท</th>}
{showCols.id && <th className="px-3 py-2">รห</th>}
{showCols.title && (
<th className="px-3 py-2">อเรอง</th>
)}
{showCols.status && (
<th className="px-3 py-2">สถานะ</th>
)}
{showCols.due && (
<th className="px-3 py-2">กำหนดส</th>
)}
{showCols.owner && (
<th className="px-3 py-2">บผดชอบ</th>
)}
{showCols.actions && (
<th className="px-3 py-2">ดการ</th>
)}
</tr>
</thead>
<tbody>
{visibleItems.length === 0 && (
<tr>
<td
className="px-3 py-8 text-center opacity-70"
colSpan={7}
>
ไมพบรายการตามตวกรองทเลอก
</td>
</tr>
)}
{visibleItems.map((row) => (
<tr
key={row.id}
className="border-b cursor-pointer hover:bg-gray-50/50"
style={{ borderColor: "#f3f3f3" }}
onClick={() => setPreviewOpen(true)}
>
{showCols.type && (
<td className="px-3 py-2">{row.t}</td>
)}
{showCols.id && (
<td className="px-3 py-2 font-mono">{row.id}</td>
)}
{showCols.title && (
<td className="px-3 py-2">{row.title}</td>
)}
{showCols.status && (
<td className="px-3 py-2">
<Tag>{row.status}</Tag>
</td>
)}
{showCols.due && (
<td className="px-3 py-2">{row.due}</td>
)}
{showCols.owner && (
<td className="px-3 py-2">{row.owner}</td>
)}
{showCols.actions && (
<td className="px-3 py-2">
<div className="flex gap-2">
<Button
size="sm"
className="rounded-xl btn-sea"
>
เป
</Button>
<Button
size="sm"
variant="outline"
className="rounded-xl"
style={{
borderColor: sea.mid,
color: sea.dark,
}}
>
Assign
</Button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
<div
className="px-4 py-2 text-xs border-t opacity-70"
style={{ borderColor: "#efefef" }}
>
เคลดล: ใช / เลอนแถว, Enter เป, /
</div>
</CardContent>
</Card>
<Tabs defaultValue="overview" className="w-full">
<TabsList
className="border rounded-2xl bg-white/80"
style={{ borderColor: "#ffffff80" }}
>
<TabsTrigger value="overview">ภาพรวม</TabsTrigger>
<TabsTrigger value="reports">รายงาน</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4 space-y-4">
<div className="grid gap-4 lg:grid-cols-5">
<Card className="border-0 rounded-2xl lg:col-span-3">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div
className="font-semibold"
style={{ color: sea.textDark }}
>
สถานะโครงการ
</div>
<Tag>Phase 3 วนท 14</Tag>
</div>
<div className="mt-4 space-y-3">
<div>
<div className="text-sm opacity-70">
ความคบหนาโดยรวม
</div>
<Progress value={62} />
</div>
<div className="grid grid-cols-3 gap-3">
<div
className="p-4 border rounded-xl"
style={{
background: sea.light,
borderColor: sea.light,
}}
>
<div className="text-xs opacity-70">วนท 1</div>
<div className="text-lg font-semibold">
เสร 70%
</div>
</div>
<div
className="p-4 border rounded-xl"
style={{
background: sea.light,
borderColor: sea.light,
}}
>
<div className="text-xs opacity-70">วนท 2</div>
<div className="text-lg font-semibold">
เสร 58%
</div>
</div>
<div
className="p-4 border rounded-xl"
style={{
background: sea.light,
borderColor: sea.light,
}}
>
<div className="text-xs opacity-70">
วนท 34
</div>
<div className="text-lg font-semibold">
เสร 59%
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 rounded-2xl lg:col-span-2">
<CardContent className="p-5 space-y-3">
<div className="flex items-center justify-between">
<div
className="font-semibold"
style={{ color: sea.textDark }}
>
System Health
</div>
<Tag>QNAP Container Station</Tag>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<Server className="w-4 h-4" /> Nginx Reverse Proxy{" "}
<span
className="ml-auto font-medium"
style={{ color: sea.dark }}
>
Healthy
</span>
</div>
<div className="flex items-center gap-2">
<Database className="w-4 h-4" /> MariaDB 10.11{" "}
<span
className="ml-auto font-medium"
style={{ color: sea.dark }}
>
OK
</span>
</div>
<div className="flex items-center gap-2">
<Workflow className="w-4 h-4" /> n8n (Postgres){" "}
<span
className="ml-auto font-medium"
style={{ color: sea.dark }}
>
OK
</span>
</div>
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" /> RBAC Enforcement{" "}
<span
className="ml-auto font-medium"
style={{ color: sea.dark }}
>
Enabled
</span>
</div>
</div>
<div
className="pt-2 border-t"
style={{ borderColor: "#eeeeee" }}
>
<Button
variant="outline"
className="rounded-xl"
style={{ borderColor: sea.mid, color: sea.dark }}
>
เปดหน /health
</Button>
</div>
</CardContent>
</Card>
</div>
<Card className="border-0 rounded-2xl">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<div
className="font-semibold"
style={{ color: sea.textDark }}
>
จกรรมลาส
</div>
<div className="flex gap-2">
<Tag>Admin</Tag>
<Tag>Editor</Tag>
<Tag>Viewer</Tag>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{recent.map((r) => (
<div
key={r.code}
className="p-4 transition border rounded-2xl hover:shadow-sm"
style={{
background: "white",
borderColor: "#efefef",
}}
>
<div className="text-xs opacity-70">
{r.type} {r.code}
</div>
<div
className="mt-1 font-medium"
style={{ color: sea.textDark }}
>
{r.title}
</div>
<div className="mt-2 text-xs opacity-70">{r.who}</div>
<div className="text-xs opacity-70">{r.when}</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="reports" className="mt-4">
<div className="grid gap-4 lg:grid-cols-2">
<Card className="border-0 rounded-2xl">
<CardContent className="p-5">
<div
className="mb-2 font-semibold"
style={{ color: sea.textDark }}
>
Report A: RFA Drawings Revisions
</div>
<div className="text-sm opacity-70">
รวมท Drawing Revision + Code
</div>
<div className="mt-3">
<Button className="rounded-2xl btn-sea">
Export CSV
</Button>
</div>
</CardContent>
</Card>
<Card className="border-0 rounded-2xl">
<CardContent className="p-5">
<div
className="mb-2 font-semibold"
style={{ color: sea.textDark }}
>
Report B: ไทมไลน RFA vs Drawing Rev
</div>
<div className="text-sm opacity-70">
Query #2 กำหนดไว
</div>
<div className="mt-3">
<Button className="rounded-2xl btn-sea">
รายงาน
</Button>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
<div className="py-6 text-xs text-center opacity-70">
Sea-themed Dashboard Sidebar อนได RBAC แสดง/อน Faceted
search KPI click-through Preview drawer Column
visibility/Density
</div>
</main>
</div>
<PreviewDrawer open={previewOpen} onClose={() => setPreviewOpen(false)}>
<div className="space-y-2 text-sm">
<div>
<span className="opacity-70">รห:</span> RFA-LCP3-0013
</div>
<div>
<span className="opacity-70">อเรอง:</span>{" "}
นยนรายละเอยดทอระบายน
</div>
<div>
<span className="opacity-70">สถานะ:</span> <Tag>Pending</Tag>
</div>
<div>
<span className="opacity-70">แนบไฟล:</span> 2 รายการ (PDF, DWG)
</div>
<div className="flex gap-2 pt-2">
{can(user, "rfa:create") && (
<Button className="btn-sea rounded-xl">แกไข</Button>
)}
<Button
variant="outline"
className="rounded-xl"
style={{ borderColor: sea.mid, color: sea.dark }}
>
เปดเตมหน
</Button>
</div>
</div>
</PreviewDrawer>
<style jsx global>{`
.btn-sea {
background: ${sea.dark};
}
.btn-sea:hover {
background: ${sea.mid};
}
.menu-sea {
background: ${sea.dark};
}
.menu-sea:hover {
background: ${sea.mid};
}
`}</style>
</div>
</TooltipProvider>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { getSession } from "@/lib/auth"; import { requireSession } from '@/lib/auth-server';
export default async function Page(){ export default async function Page() {
const { user } = await getSession(); const { user } = await requireSession();
return <div className="rounded-2xl p-5 bg-white">Drawings list/table (อเชอม backend)</div>; return <div className="p-5 bg-white rounded-2xl">Drawings list/table (อเชอม backend)</div>;
} }

View File

@@ -1,3 +1,3 @@
export default function Page(){ export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Upload Wizard 3 (เลอกไฟล Volume/Sub-cat Review)</div>; return <div className="rounded-2xl p-5 bg-white">Upload Wizard 3 (เลอกไฟล Volume/Sub-cat Review)</div>;
} }

View File

@@ -1,3 +1,3 @@
export default function Page(){ export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Health แสดงสถานะ service (nginx, maria, n8n, postgres)</div>; return <div className="rounded-2xl p-5 bg-white">Health แสดงสถานะ service (nginx, maria, n8n, postgres)</div>;
} }

View File

@@ -1,182 +1,95 @@
// frontend/app/(protected)/layout.jsx // File: frontend/app/(protected)/layout.jsx
import Link from "next/link"; 'use client';
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth"; import { useEffect } from 'react';
import { can } from "@/lib/rbac"; import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
export const metadata = { title: "DMS | Protected" };
import { Bell, LogOut, Users } from 'lucide-react';
export default async function ProtectedLayout({ children }) { import { Button } from '@/components/ui/button';
// ตรวจ session ฝั่งเซิร์ฟเวอร์ ด้วยคุกกี้จริง import {
const session = await getSession(); DropdownMenu,
if (!session) { DropdownMenuContent,
redirect("/login"); DropdownMenuItem,
} DropdownMenuLabel,
const { user } = session; DropdownMenuSeparator,
DropdownMenuTrigger,
return ( } from '@/components/ui/dropdown-menu';
<section className="grid grid-cols-12 gap-6 p-4 mx-auto max-w-7xl">
<aside className="col-span-12 lg:col-span-3 xl:col-span-3"> // NOTE: ให้ชี้ไปยังไฟล์จริงของคุณ
<div className="p-4 border rounded-3xl bg-white/70"> // เดิมบางโปรเจ็กต์ใช้ "../_components/SideNavigation"
<div className="mb-3 text-sm"> // ที่นี่อ้าง absolute import ตาม tsconfig/baseUrl
RBAC: <b>{user.role}</b> import { SideNavigation } from '@/app/_components/SideNavigation';
</div>
export default function ProtectedLayout({ children }) {
<nav className="space-y-2"> const { user, isAuthenticated, loading, logout } = useAuth();
<Link const router = useRouter();
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white"
href="/dashboard" // Guard ฝั่ง client: ถ้าไม่ได้ล็อกอิน ให้เด้งไป /login
> useEffect(() => {
แดชบอร if (!loading && !isAuthenticated) {
</Link> router.push('/login');
<Link }
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" }, [loading, isAuthenticated, router]);
href="/drawings"
> // ระหว่างรอเช็คสถานะ หรือยังไม่ authenticated -> แสดง loading
Drawings if (loading || !isAuthenticated) {
</Link> return (
<Link <div className="flex items-center justify-center h-screen">
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" <div className="text-sm text-muted-foreground">Loading session</div>
href="/rfas" </div>
> );
RFAs }
</Link>
<Link const handleLogout = async () => {
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" try {
href="/transmittals" await logout();
> } finally {
Transmittals router.replace('/login');
</Link> }
<Link };
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white"
href="/correspondences" return (
> <div className="grid min-h-screen w-full md:grid-cols-[220px_1fr] lg:grid-cols-[280px_1fr]">
Correspondences {/* Sidebar */}
</Link> <aside className="hidden border-r bg-muted/40 md:block">
<Link <SideNavigation user={user} />
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" </aside>
href="/contracts-volumes"
> {/* Main */}
Contracts & Volumes <div className="flex flex-col">
</Link> <header className="flex h-14 items-center gap-4 border-b bg-muted/40 px-4 lg:h-[60px] lg:px-6">
<Link <div className="flex-1" />
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white"
href="/reports" <Button variant="ghost" size="icon" className="relative">
> <Bell className="w-5 h-5" />
Reports <span className="absolute inline-flex w-2 h-2 rounded-full right-1 top-1 bg-primary" />
</Link> </Button>
{can(user, "workflow:view") && ( <DropdownMenu>
<Link <DropdownMenuTrigger asChild>
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" <Button variant="secondary" size="icon" className="rounded-full">
href="/workflow" <Users className="w-5 h-5" />
> <span className="sr-only">Toggle user menu</span>
Workflow (n8n) </Button>
</Link> </DropdownMenuTrigger>
)} <DropdownMenuContent align="end">
{can(user, "health:view") && ( <DropdownMenuLabel>{user?.username || 'My Account'}</DropdownMenuLabel>
<Link <DropdownMenuSeparator />
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" <DropdownMenuItem>Profile Settings</DropdownMenuItem>
href="/health" <DropdownMenuSeparator />
> <DropdownMenuItem onClick={handleLogout} className="text-red-500 focus:text-red-600">
Health <LogOut className="w-4 h-4 mr-2" />
</Link> <span>Logout</span>
)} </DropdownMenuItem>
{can(user, "users:manage") && ( </DropdownMenuContent>
<Link </DropdownMenu>
className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" </header>
href="/users"
> <main className="flex flex-col flex-1 gap-4 p-4 lg:gap-6 lg:p-6">
ใช/บทบาท {children}
</Link> </main>
)} </div>
</nav> </div>
</div> );
</aside> }
<main className="col-span-12 space-y-6 lg:col-span-9 xl:col-span-9">
{/* System / Quick Actions */}
<div className="flex items-center gap-2">
<div className="flex-1 text-lg font-semibold">
Document Management System LCP3 Phase 3
</div>
{can(user, "admin:view") && (
<a
className="px-3 py-2 text-white rounded-xl"
style={{ background: "#0D5C75" }}
href="/admin"
>
Admin
</a>
)}
{can(user, "users:manage") && (
<a
className="px-3 py-2 text-white rounded-xl"
style={{ background: "#0D5C75" }}
href="/users"
>
ใช/บทบาท
</a>
)}
{can(user, "health:view") && (
<a
className="px-3 py-2 text-white rounded-xl"
style={{ background: "#0D5C75" }}
href="/health"
>
Health
</a>
)}
{can(user, "workflow:view") && (
<a
className="px-3 py-2 text-white rounded-xl"
style={{ background: "#0D5C75" }}
href="/workflow"
>
Workflow
</a>
)}
{can(user, "rfa:create") && (
<a
className="px-3 py-2 text-white rounded-xl"
style={{ background: "#0D5C75" }}
href="/rfas/new"
>
+ RFA
</a>
)}
{can(user, "drawing:upload") && (
<a
className="px-3 py-2 text-white rounded-xl"
style={{ background: "#0D5C75" }}
href="/drawings/upload"
>
+ Upload Drawing
</a>
)}
{can(user, "transmittal:create") && (
<a
className="px-3 py-2 text-white rounded-xl"
style={{ background: "#0D5C75" }}
href="/transmittals/new"
>
+ Transmittal
</a>
)}
{can(user, "correspondence:create") && (
<a
className="px-3 py-2 text-white rounded-xl"
style={{ background: "#0D5C75" }}
href="/correspondences/new"
>
+ หนงสอสอสาร
</a>
)}
</div>
{children}
</main>
</section>
);
}

View File

@@ -1,3 +1,3 @@
export default function Page(){ export default function Page(){
return <div className="rounded-2xl p-5 bg-white">Reports Export CSV/PDF</div>; return <div className="rounded-2xl p-5 bg-white">Reports Export CSV/PDF</div>;
} }

View File

@@ -1,110 +1,110 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
export default function RfaNew() { export default function RfaNew() {
const router = useRouter(); const router = useRouter();
const [draftId, setDraftId] = React.useState(null); const [draftId, setDraftId] = React.useState(null);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [savedAt, setSavedAt] = React.useState(null); const [savedAt, setSavedAt] = React.useState(null);
const [error, setError] = React.useState(""); const [error, setError] = React.useState("");
const [form, setForm] = React.useState({ const [form, setForm] = React.useState({
title: "", code: "", discipline: "", due_date: "", description: "" title: "", code: "", discipline: "", due_date: "", description: ""
}); });
const [errs, setErrs] = React.useState({}); const [errs, setErrs] = React.useState({});
// simple validate (client) // simple validate (client)
const validate = (f) => { const validate = (f) => {
const e = {}; const e = {};
if (!f.title?.trim()) e.title = "กรุณากรอกชื่อเรื่อง"; if (!f.title?.trim()) e.title = "กรุณากรอกชื่อเรื่อง";
if (!f.due_date) e.due_date = "กรุณากำหนดวันที่ครบกำหนด"; if (!f.due_date) e.due_date = "กรุณากำหนดวันที่ครบกำหนด";
return e; return e;
}; };
// debounce autosave // debounce autosave
const tRef = React.useRef(0); const tRef = React.useRef(0);
React.useEffect(() => { React.useEffect(() => {
clearTimeout(tRef.current); clearTimeout(tRef.current);
tRef.current = window.setTimeout(async () => { tRef.current = window.setTimeout(async () => {
const e = validate(form); const e = validate(form);
setErrs(e); // แสดง error ทันที (soft) setErrs(e); // แสดง error ทันที (soft)
try { try {
setSaving(true); setSaving(true);
if (!draftId) { if (!draftId) {
// create draft // create draft
const res = await api("/rfas", { method: "POST", body: { ...form, status: "draft" } }); const res = await api("/rfas", { method: "POST", body: { ...form, status: "draft" } });
setDraftId(res.id); setDraftId(res.id);
} else { } else {
// update draft // update draft
await api(`/rfas/${draftId}`, { method: "PATCH", body: { ...form, status: "draft" } }); await api(`/rfas/${draftId}`, { method: "PATCH", body: { ...form, status: "draft" } });
} }
setSavedAt(new Date()); setSavedAt(new Date());
} catch (err) { } catch (err) {
setError(err.message || "บันทึกฉบับร่างไม่สำเร็จ"); setError(err.message || "บันทึกฉบับร่างไม่สำเร็จ");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, 800); }, 800);
return () => clearTimeout(tRef.current); return () => clearTimeout(tRef.current);
}, [form, draftId]); }, [form, draftId]);
const onSubmit = async (e) => { const onSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
const eobj = validate(form); const eobj = validate(form);
setErrs(eobj); setErrs(eobj);
if (Object.keys(eobj).length) return; if (Object.keys(eobj).length) return;
try { try {
setSaving(true); setSaving(true);
const id = draftId const id = draftId
? (await api(`/rfas/${draftId}`, { method: "PATCH", body: { ...form, status: "submitted" } })).id || draftId ? (await api(`/rfas/${draftId}`, { method: "PATCH", body: { ...form, status: "submitted" } })).id || draftId
: (await api("/rfas", { method: "POST", body: { ...form, status: "submitted" } })).id; : (await api("/rfas", { method: "POST", body: { ...form, status: "submitted" } })).id;
router.replace(`/rfas`); // หรือไปหน้า detail `/rfas/${id}` router.replace(`/rfas`); // หรือไปหน้า detail `/rfas/${id}`
} catch (err) { } catch (err) {
setError(err.message || "ส่งคำขอไม่สำเร็จ"); setError(err.message || "ส่งคำขอไม่สำเร็จ");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
return ( return (
<form onSubmit={onSubmit} className="p-5 space-y-4 bg-white rounded-2xl"> <form onSubmit={onSubmit} className="p-5 space-y-4 bg-white rounded-2xl">
<div className="text-lg font-semibold">สราง RFA</div> <div className="text-lg font-semibold">สราง RFA</div>
{error && <div className="text-sm text-red-600">{error}</div>} {error && <div className="text-sm text-red-600">{error}</div>}
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div> <div>
<label className="text-sm">อเรอง *</label> <label className="text-sm">อเรอง *</label>
<Input value={form.title} onChange={(e)=>setForm(f=>({...f, title:e.target.value}))}/> <Input value={form.title} onChange={(e)=>setForm(f=>({...f, title:e.target.value}))}/>
{errs.title && <div className="mt-1 text-xs text-red-600">{errs.title}</div>} {errs.title && <div className="mt-1 text-xs text-red-600">{errs.title}</div>}
</div> </div>
<div> <div>
<label className="text-sm">รห (าม)</label> <label className="text-sm">รห (าม)</label>
<Input value={form.code} onChange={(e)=>setForm(f=>({...f, code:e.target.value}))}/> <Input value={form.code} onChange={(e)=>setForm(f=>({...f, code:e.target.value}))}/>
</div> </div>
<div> <div>
<label className="text-sm">สาขา/หมวด (Discipline)</label> <label className="text-sm">สาขา/หมวด (Discipline)</label>
<Input value={form.discipline} onChange={(e)=>setForm(f=>({...f, discipline:e.target.value}))}/> <Input value={form.discipline} onChange={(e)=>setForm(f=>({...f, discipline:e.target.value}))}/>
</div> </div>
<div> <div>
<label className="text-sm">กำหนดส *</label> <label className="text-sm">กำหนดส *</label>
<input type="date" className="w-full p-2 border rounded-xl" value={form.due_date} <input type="date" className="w-full p-2 border rounded-xl" value={form.due_date}
onChange={(e)=>setForm(f=>({...f, due_date:e.target.value}))}/> onChange={(e)=>setForm(f=>({...f, due_date:e.target.value}))}/>
{errs.due_date && <div className="mt-1 text-xs text-red-600">{errs.due_date}</div>} {errs.due_date && <div className="mt-1 text-xs text-red-600">{errs.due_date}</div>}
</div> </div>
</div> </div>
<div> <div>
<label className="text-sm">รายละเอยด</label> <label className="text-sm">รายละเอยด</label>
<textarea rows={5} className="w-full p-2 border rounded-xl" <textarea rows={5} className="w-full p-2 border rounded-xl"
value={form.description} onChange={(e)=>setForm(f=>({...f, description:e.target.value}))}/> value={form.description} onChange={(e)=>setForm(f=>({...f, description:e.target.value}))}/>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button type="submit" disabled={saving}>งเพอพจารณา</Button> <Button type="submit" disabled={saving}>งเพอพจารณา</Button>
<span className="text-sm opacity-70"> <span className="text-sm opacity-70">
{saving ? "กำลังบันทึก…" : savedAt ? `บันทึกล่าสุด ${savedAt.toLocaleTimeString()}` : "ยังไม่เคยบันทึก"} {saving ? "กำลังบันทึก…" : savedAt ? `บันทึกล่าสุด ${savedAt.toLocaleTimeString()}` : "ยังไม่เคยบันทึก"}
</span> </span>
</div> </div>
</form> </form>
); );
} }

View File

@@ -1,135 +1,135 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { apiGet } from "@/lib/api"; import { apiGet } from "@/lib/api";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
export default function RFAsPage() { export default function RFAsPage() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const sp = useSearchParams(); const sp = useSearchParams();
// params from URL // params from URL
const [q, setQ] = React.useState(sp.get("q") || ""); const [q, setQ] = React.useState(sp.get("q") || "");
const status = sp.get("status") || "All"; const status = sp.get("status") || "All";
const overdue = sp.get("overdue") === "1"; const overdue = sp.get("overdue") === "1";
const page = Number(sp.get("page") || 1); const page = Number(sp.get("page") || 1);
const pageSize = Number(sp.get("pageSize") || 20); const pageSize = Number(sp.get("pageSize") || 20);
const sort = sp.get("sort") || "updated_at:desc"; const sort = sp.get("sort") || "updated_at:desc";
const setParams = (patch) => { const setParams = (patch) => {
const curr = Object.fromEntries(sp.entries()); const curr = Object.fromEntries(sp.entries());
const next = { ...curr, ...patch }; const next = { ...curr, ...patch };
// normalize // normalize
if (!next.q) delete next.q; if (!next.q) delete next.q;
if (!next.status || next.status === "All") delete next.status; if (!next.status || next.status === "All") delete next.status;
if (!next.overdue || next.overdue === "0") delete next.overdue; if (!next.overdue || next.overdue === "0") delete next.overdue;
if (!next.page || Number(next.page) === 1) delete next.page; if (!next.page || Number(next.page) === 1) delete next.page;
if (!next.pageSize || Number(next.pageSize) === 20) delete next.pageSize; if (!next.pageSize || Number(next.pageSize) === 20) delete next.pageSize;
if (!next.sort || next.sort === "updated_at:desc") delete next.sort; if (!next.sort || next.sort === "updated_at:desc") delete next.sort;
const usp = new URLSearchParams(next).toString(); const usp = new URLSearchParams(next).toString();
router.replace(`${pathname}${usp ? `?${usp}` : ""}`); router.replace(`${pathname}${usp ? `?${usp}` : ""}`);
}; };
const [rows, setRows] = React.useState([]); const [rows, setRows] = React.useState([]);
const [total, setTotal] = React.useState(0); const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(""); const [error, setError] = React.useState("");
// fetch whenever URL params change // fetch whenever URL params change
React.useEffect(() => { React.useEffect(() => {
setLoading(true); setError(""); setLoading(true); setError("");
apiGet("/rfas", { apiGet("/rfas", {
q, status: status !== "All" ? status : undefined, q, status: status !== "All" ? status : undefined,
overdue: overdue ? 1 : undefined, page, pageSize, sort overdue: overdue ? 1 : undefined, page, pageSize, sort
}).then((res) => { }).then((res) => {
// expected: { data: [...], page, pageSize, total } // expected: { data: [...], page, pageSize, total }
setRows(res.data || []); setRows(res.data || []);
setTotal(res.total || 0); setTotal(res.total || 0);
}).catch((e) => { }).catch((e) => {
setError(e.message || "โหลดข้อมูลไม่สำเร็จ"); setError(e.message || "โหลดข้อมูลไม่สำเร็จ");
}).finally(() => setLoading(false)); }).finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [sp]); }, [sp]);
const pages = Math.max(1, Math.ceil(total / pageSize)); const pages = Math.max(1, Math.ceil(total / pageSize));
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
placeholder="ค้นหา (รหัส/ชื่อเรื่อง/ผู้รับผิดชอบ)" placeholder="ค้นหา (รหัส/ชื่อเรื่อง/ผู้รับผิดชอบ)"
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && setParams({ q, page: 1 })} onKeyDown={(e) => e.key === "Enter" && setParams({ q, page: 1 })}
/> />
<select <select
className="border rounded-xl p-2" className="border rounded-xl p-2"
value={status} value={status}
onChange={(e) => setParams({ status: e.target.value, page: 1 })} onChange={(e) => setParams({ status: e.target.value, page: 1 })}
> >
<option>All</option><option>Pending</option><option>Review</option><option>Approved</option><option>Closed</option> <option>All</option><option>Pending</option><option>Review</option><option>Approved</option><option>Closed</option>
</select> </select>
<label className="text-sm flex items-center gap-2"> <label className="text-sm flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={overdue} checked={overdue}
onChange={(e) => setParams({ overdue: e.target.checked ? "1" : "0", page: 1 })} onChange={(e) => setParams({ overdue: e.target.checked ? "1" : "0", page: 1 })}
/> />
Overdue Overdue
</label> </label>
<Button onClick={() => setParams({ q, page: 1 })}>นหา</Button> <Button onClick={() => setParams({ q, page: 1 })}>นหา</Button>
</div> </div>
<Card className="rounded-2xl border-0"> <Card className="rounded-2xl border-0">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead className="bg-white sticky top-0 border-b"> <thead className="bg-white sticky top-0 border-b">
<tr className="text-left"> <tr className="text-left">
<th className="py-2 px-3">รห</th> <th className="py-2 px-3">รห</th>
<th className="py-2 px-3">อเรอง</th> <th className="py-2 px-3">อเรอง</th>
<th className="py-2 px-3">สถานะ</th> <th className="py-2 px-3">สถานะ</th>
<th className="py-2 px-3">กำหนดส</th> <th className="py-2 px-3">กำหนดส</th>
<th className="py-2 px-3">บผดชอบ</th> <th className="py-2 px-3">บผดชอบ</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading && <tr><td className="py-6 px-3" colSpan={5}>กำลงโหลด</td></tr>} {loading && <tr><td className="py-6 px-3" colSpan={5}>กำลงโหลด</td></tr>}
{error && !loading && <tr><td className="py-6 px-3 text-red-600" colSpan={5}>{error}</td></tr>} {error && !loading && <tr><td className="py-6 px-3 text-red-600" colSpan={5}>{error}</td></tr>}
{!loading && !error && rows.length === 0 && <tr><td className="py-6 px-3 opacity-70" colSpan={5}>ไมพบขอม</td></tr>} {!loading && !error && rows.length === 0 && <tr><td className="py-6 px-3 opacity-70" colSpan={5}>ไมพบขอม</td></tr>}
{!loading && !error && rows.map((r) => ( {!loading && !error && rows.map((r) => (
<tr key={r.id} className="border-b hover:bg-gray-50"> <tr key={r.id} className="border-b hover:bg-gray-50">
<td className="py-2 px-3 font-mono">{r.code || r.id}</td> <td className="py-2 px-3 font-mono">{r.code || r.id}</td>
<td className="py-2 px-3">{r.title}</td> <td className="py-2 px-3">{r.title}</td>
<td className="py-2 px-3">{r.status}</td> <td className="py-2 px-3">{r.status}</td>
<td className="py-2 px-3">{r.due_date || "—"}</td> <td className="py-2 px-3">{r.due_date || "—"}</td>
<td className="py-2 px-3">{r.owner_name || "—"}</td> <td className="py-2 px-3">{r.owner_name || "—"}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="flex items-center justify-between px-3 py-2 text-sm border-t"> <div className="flex items-center justify-between px-3 py-2 text-sm border-t">
<span>งหมด {total} รายการ</span> <span>งหมด {total} รายการ</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={() => setParams({ page: Math.max(1, page - 1) })} onClick={() => setParams({ page: Math.max(1, page - 1) })}
disabled={page <= 1} disabled={page <= 1}
>อนกล</Button> >อนกล</Button>
<span>หน {page}/{pages}</span> <span>หน {page}/{pages}</span>
<Button <Button
variant="outline" variant="outline"
onClick={() => setParams({ page: Math.min(pages, page + 1) })} onClick={() => setParams({ page: Math.min(pages, page + 1) })}
disabled={page >= pages} disabled={page >= pages}
>ดไป</Button> >ดไป</Button>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }

View File

@@ -1,108 +1,108 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
export default function TransmittalNew() { export default function TransmittalNew() {
const router = useRouter(); const router = useRouter();
const [draftId, setDraftId] = React.useState(null); const [draftId, setDraftId] = React.useState(null);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [savedAt, setSavedAt] = React.useState(null); const [savedAt, setSavedAt] = React.useState(null);
const [error, setError] = React.useState(""); const [error, setError] = React.useState("");
const [form, setForm] = React.useState({ const [form, setForm] = React.useState({
subject: "", number: "", to_party: "", sent_date: "", description: "" subject: "", number: "", to_party: "", sent_date: "", description: ""
}); });
const [errs, setErrs] = React.useState({}); const [errs, setErrs] = React.useState({});
const validate = (f) => { const validate = (f) => {
const e = {}; const e = {};
if (!f.subject?.trim()) e.subject = "กรุณากรอกเรื่อง (Subject)"; if (!f.subject?.trim()) e.subject = "กรุณากรอกเรื่อง (Subject)";
if (!f.to_party?.trim()) e.to_party = "กรุณาระบุผู้รับ (To)"; if (!f.to_party?.trim()) e.to_party = "กรุณาระบุผู้รับ (To)";
if (!f.sent_date) e.sent_date = "กรุณาระบุวันที่ส่ง"; if (!f.sent_date) e.sent_date = "กรุณาระบุวันที่ส่ง";
return e; return e;
}; };
const tRef = React.useRef(0); const tRef = React.useRef(0);
React.useEffect(() => { React.useEffect(() => {
clearTimeout(tRef.current); clearTimeout(tRef.current);
tRef.current = window.setTimeout(async () => { tRef.current = window.setTimeout(async () => {
const e = validate(form); const e = validate(form);
setErrs(e); setErrs(e);
try { try {
setSaving(true); setSaving(true);
if (!draftId) { if (!draftId) {
const res = await api("/transmittals", { method: "POST", body: { ...form, status: "draft" } }); const res = await api("/transmittals", { method: "POST", body: { ...form, status: "draft" } });
setDraftId(res.id); setDraftId(res.id);
} else { } else {
await api(`/transmittals/${draftId}`, { method: "PATCH", body: { ...form, status: "draft" } }); await api(`/transmittals/${draftId}`, { method: "PATCH", body: { ...form, status: "draft" } });
} }
setSavedAt(new Date()); setSavedAt(new Date());
} catch (err) { } catch (err) {
setError(err.message || "บันทึกฉบับร่างไม่สำเร็จ"); setError(err.message || "บันทึกฉบับร่างไม่สำเร็จ");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, 800); }, 800);
return () => clearTimeout(tRef.current); return () => clearTimeout(tRef.current);
}, [form, draftId]); }, [form, draftId]);
const onSubmit = async (e) => { const onSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
const eobj = validate(form); const eobj = validate(form);
setErrs(eobj); setErrs(eobj);
if (Object.keys(eobj).length) return; if (Object.keys(eobj).length) return;
try { try {
setSaving(true); setSaving(true);
const id = draftId const id = draftId
? (await api(`/transmittals/${draftId}`, { method: "PATCH", body: { ...form, status: "submitted" } })).id || draftId ? (await api(`/transmittals/${draftId}`, { method: "PATCH", body: { ...form, status: "submitted" } })).id || draftId
: (await api("/transmittals", { method: "POST", body: { ...form, status: "submitted" } })).id; : (await api("/transmittals", { method: "POST", body: { ...form, status: "submitted" } })).id;
router.replace(`/transmittals`); router.replace(`/transmittals`);
} catch (err) { } catch (err) {
setError(err.message || "ส่ง Transmittal ไม่สำเร็จ"); setError(err.message || "ส่ง Transmittal ไม่สำเร็จ");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
return ( return (
<form onSubmit={onSubmit} className="space-y-4 rounded-2xl p-5 bg-white"> <form onSubmit={onSubmit} className="space-y-4 rounded-2xl p-5 bg-white">
<div className="text-lg font-semibold">สราง Transmittal</div> <div className="text-lg font-semibold">สราง Transmittal</div>
{error && <div className="text-sm text-red-600">{error}</div>} {error && <div className="text-sm text-red-600">{error}</div>}
<div className="grid md:grid-cols-2 gap-3"> <div className="grid md:grid-cols-2 gap-3">
<div> <div>
<label className="text-sm">เรอง (Subject) *</label> <label className="text-sm">เรอง (Subject) *</label>
<Input value={form.subject} onChange={(e)=>setForm(f=>({...f, subject:e.target.value}))}/> <Input value={form.subject} onChange={(e)=>setForm(f=>({...f, subject:e.target.value}))}/>
{errs.subject && <div className="text-xs text-red-600 mt-1">{errs.subject}</div>} {errs.subject && <div className="text-xs text-red-600 mt-1">{errs.subject}</div>}
</div> </div>
<div> <div>
<label className="text-sm">เลขท (าม)</label> <label className="text-sm">เลขท (าม)</label>
<Input value={form.number} onChange={(e)=>setForm(f=>({...f, number:e.target.value}))}/> <Input value={form.number} onChange={(e)=>setForm(f=>({...f, number:e.target.value}))}/>
</div> </div>
<div> <div>
<label className="text-sm"> (To) *</label> <label className="text-sm"> (To) *</label>
<Input value={form.to_party} onChange={(e)=>setForm(f=>({...f, to_party:e.target.value}))}/> <Input value={form.to_party} onChange={(e)=>setForm(f=>({...f, to_party:e.target.value}))}/>
{errs.to_party && <div className="text-xs text-red-600 mt-1">{errs.to_party}</div>} {errs.to_party && <div className="text-xs text-red-600 mt-1">{errs.to_party}</div>}
</div> </div>
<div> <div>
<label className="text-sm">นท *</label> <label className="text-sm">นท *</label>
<input type="date" className="border rounded-xl p-2 w-full" value={form.sent_date} <input type="date" className="border rounded-xl p-2 w-full" value={form.sent_date}
onChange={(e)=>setForm(f=>({...f, sent_date:e.target.value}))}/> onChange={(e)=>setForm(f=>({...f, sent_date:e.target.value}))}/>
{errs.sent_date && <div className="text-xs text-red-600 mt-1">{errs.sent_date}</div>} {errs.sent_date && <div className="text-xs text-red-600 mt-1">{errs.sent_date}</div>}
</div> </div>
</div> </div>
<div> <div>
<label className="text-sm">รายละเอยด</label> <label className="text-sm">รายละเอยด</label>
<textarea rows={5} className="border rounded-xl p-2 w-full" <textarea rows={5} className="border rounded-xl p-2 w-full"
value={form.description} onChange={(e)=>setForm(f=>({...f, description:e.target.value}))}/> value={form.description} onChange={(e)=>setForm(f=>({...f, description:e.target.value}))}/>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button type="submit" disabled={saving}> Transmittal</Button> <Button type="submit" disabled={saving}> Transmittal</Button>
<span className="text-sm opacity-70"> <span className="text-sm opacity-70">
{saving ? "กำลังบันทึก…" : savedAt ? `บันทึกล่าสุด ${savedAt.toLocaleTimeString()}` : "ยังไม่เคยบันทึก"} {saving ? "กำลังบันทึก…" : savedAt ? `บันทึกล่าสุด ${savedAt.toLocaleTimeString()}` : "ยังไม่เคยบันทึก"}
</span> </span>
</div> </div>
</form> </form>
); );
} }

View File

@@ -1,96 +1,96 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { apiGet } from "@/lib/api"; import { apiGet } from "@/lib/api";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
export default function TransmittalsPage() { export default function TransmittalsPage() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const sp = useSearchParams(); const sp = useSearchParams();
const [q, setQ] = React.useState(sp.get("q") || ""); const [q, setQ] = React.useState(sp.get("q") || "");
const page = Number(sp.get("page") || 1); const page = Number(sp.get("page") || 1);
const pageSize = Number(sp.get("pageSize") || 20); const pageSize = Number(sp.get("pageSize") || 20);
const sort = sp.get("sort") || "sent_date:desc"; const sort = sp.get("sort") || "sent_date:desc";
const setParams = (patch) => { const setParams = (patch) => {
const curr = Object.fromEntries(sp.entries()); const curr = Object.fromEntries(sp.entries());
const next = { ...curr, ...patch }; const next = { ...curr, ...patch };
if (!next.q) delete next.q; if (!next.q) delete next.q;
if (!next.page || Number(next.page) === 1) delete next.page; if (!next.page || Number(next.page) === 1) delete next.page;
if (!next.pageSize || Number(next.pageSize) === 20) delete next.pageSize; if (!next.pageSize || Number(next.pageSize) === 20) delete next.pageSize;
if (!next.sort || next.sort === "sent_date:desc") delete next.sort; if (!next.sort || next.sort === "sent_date:desc") delete next.sort;
const usp = new URLSearchParams(next).toString(); const usp = new URLSearchParams(next).toString();
router.replace(`${pathname}${usp ? `?${usp}` : ""}`); router.replace(`${pathname}${usp ? `?${usp}` : ""}`);
}; };
const [rows, setRows] = React.useState([]); const [rows, setRows] = React.useState([]);
const [total, setTotal] = React.useState(0); const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(""); const [error, setError] = React.useState("");
React.useEffect(() => { React.useEffect(() => {
setLoading(true); setError(""); setLoading(true); setError("");
apiGet("/transmittals", { q, page, pageSize, sort }) apiGet("/transmittals", { q, page, pageSize, sort })
.then((res) => { setRows(res.data || []); setTotal(res.total || 0); }) .then((res) => { setRows(res.data || []); setTotal(res.total || 0); })
.catch((e) => setError(e.message || "โหลดข้อมูลไม่สำเร็จ")) .catch((e) => setError(e.message || "โหลดข้อมูลไม่สำเร็จ"))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [sp]); }, [sp]);
const pages = Math.max(1, Math.ceil(total / pageSize)); const pages = Math.max(1, Math.ceil(total / pageSize));
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
placeholder="ค้นหา Transmittal (เลขที่/เรื่อง/ถึงใคร)" placeholder="ค้นหา Transmittal (เลขที่/เรื่อง/ถึงใคร)"
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && setParams({ q, page: 1 })} onKeyDown={(e) => e.key === "Enter" && setParams({ q, page: 1 })}
/> />
<Button onClick={() => setParams({ q, page: 1 })}>นหา</Button> <Button onClick={() => setParams({ q, page: 1 })}>นหา</Button>
</div> </div>
<Card className="border-0 rounded-2xl"> <Card className="border-0 rounded-2xl">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead className="sticky top-0 bg-white border-b"> <thead className="sticky top-0 bg-white border-b">
<tr className="text-left"> <tr className="text-left">
<th className="px-3 py-2">เลขท</th> <th className="px-3 py-2">เลขท</th>
<th className="px-3 py-2">เรอง</th> <th className="px-3 py-2">เรอง</th>
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
<th className="px-3 py-2">นท</th> <th className="px-3 py-2">นท</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading && <tr><td className="px-3 py-6" colSpan={4}>กำลงโหลด</td></tr>} {loading && <tr><td className="px-3 py-6" colSpan={4}>กำลงโหลด</td></tr>}
{error && !loading && <tr><td className="px-3 py-6 text-red-600" colSpan={4}>{error}</td></tr>} {error && !loading && <tr><td className="px-3 py-6 text-red-600" colSpan={4}>{error}</td></tr>}
{!loading && !error && rows.length === 0 && <tr><td className="px-3 py-6 opacity-70" colSpan={4}>ไมพบขอม</td></tr>} {!loading && !error && rows.length === 0 && <tr><td className="px-3 py-6 opacity-70" colSpan={4}>ไมพบขอม</td></tr>}
{!loading && !error && rows.map((r) => ( {!loading && !error && rows.map((r) => (
<tr key={r.id} className="border-b hover:bg-gray-50"> <tr key={r.id} className="border-b hover:bg-gray-50">
<td className="px-3 py-2 font-mono">{r.number || r.id}</td> <td className="px-3 py-2 font-mono">{r.number || r.id}</td>
<td className="px-3 py-2">{r.subject}</td> <td className="px-3 py-2">{r.subject}</td>
<td className="px-3 py-2">{r.to_party}</td> <td className="px-3 py-2">{r.to_party}</td>
<td className="px-3 py-2">{r.sent_date || "—"}</td> <td className="px-3 py-2">{r.sent_date || "—"}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="flex items-center justify-between px-3 py-2 text-sm border-t"> <div className="flex items-center justify-between px-3 py-2 text-sm border-t">
<span>งหมด {total} รายการ</span> <span>งหมด {total} รายการ</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setParams({ page: Math.max(1, page - 1) })} disabled={page <= 1}>อนกล</Button> <Button variant="outline" onClick={() => setParams({ page: Math.max(1, page - 1) })} disabled={page <= 1}>อนกล</Button>
<span>หน {page}/{pages}</span> <span>หน {page}/{pages}</span>
<Button variant="outline" onClick={() => setParams({ page: Math.min(pages, page + 1) })} disabled={page >= pages}>ดไป</Button> <Button variant="outline" onClick={() => setParams({ page: Math.min(pages, page + 1) })} disabled={page >= pages}>ดไป</Button>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }

View File

@@ -1,3 +1,3 @@
export default function Page(){ export default function Page(){
return <div className="rounded-2xl p-5 bg-white">ใช/บทบาท ดการ RBAC</div>; return <div className="rounded-2xl p-5 bg-white">ใช/บทบาท ดการ RBAC</div>;
} }

Some files were not shown because too many files have changed in this diff Show More