From 905afb56f5fb23ec59b5688a7efbe90a5d1d00cb Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 1 Oct 2025 11:14:11 +0700 Subject: [PATCH] =?UTF-8?q?05.1=20=E0=B8=9B=E0=B8=A3=E0=B8=9A=E0=B8=9B?= =?UTF-8?q?=E0=B8=A3=E0=B8=87=20backend=20=E0=B8=97=E0=B8=87=E0=B8=AB?= =?UTF-8?q?=E0=B8=A1=E0=B8=94=20=E0=B9=81=E0=B8=A5=E0=B8=B0=20frontend/log?= =?UTF-8?q?in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + backend/README2.md | 159 ++++++++ backend/src/config.js | 54 +-- backend/src/config/permissions.js | 188 +++------ backend/src/db/index.js | 37 +- backend/src/db/sequelize.js | 89 +++-- backend/src/index.js | 177 +++------ backend/src/middleware/authJwt.js | 17 +- backend/src/routes/admin.js | 118 ++---- backend/src/routes/auth_extras.js | 37 +- backend/src/routes/categories.js | 108 ++---- backend/src/routes/contract_dwg.js | 186 +++++---- backend/src/routes/contracts.js | 126 +++--- backend/src/routes/correspondences.js | 180 ++++----- backend/src/routes/documents.js | 182 ++++++--- backend/src/routes/drawings.js | 158 ++++---- backend/src/routes/files_extras.js | 225 +++++------ backend/src/routes/health.js | 19 +- backend/src/routes/list.txt | 41 -- backend/src/routes/lookup.js | 257 ++++++------- backend/src/routes/maps.js | 271 ++++++------- backend/src/routes/module_files.js | 99 ++++- backend/src/routes/mvp.js | 109 +++--- backend/src/routes/ops.js | 19 +- backend/src/routes/organizations.js | 91 ++--- backend/src/routes/permissions.js | 27 +- backend/src/routes/projects.js | 120 +++--- backend/src/routes/rbac_admin.js | 92 ++--- backend/src/routes/rfa.js | 70 ++-- backend/src/routes/rfas.js | 351 ++++++----------- backend/src/routes/subcategories.js | 136 +++---- backend/src/routes/technicaldocs.js | 284 +++++--------- backend/src/routes/transmittals.js | 360 +++++------------- backend/src/routes/uploads.js | 113 +++--- backend/src/routes/users.js | 74 ++-- backend/src/routes/users_extras.js | 114 ++---- backend/src/routes/view.js | 254 +++++------- backend/src/routes/views.js | 55 +-- backend/src/routes/volumes.js | 93 ++--- backend/src/utils/passwords.js | 2 +- backend/src/utils/rbac.js | 2 +- backend/src/utils/scope.js | 2 +- ...dms_v0_5_0_data_v5_1_deploy_table_rbac.sql | 22 ++ 43 files changed, 2285 insertions(+), 2834 deletions(-) create mode 100644 backend/README2.md delete mode 100644 backend/src/routes/list.txt diff --git a/.gitignore b/.gitignore index 3cc45714..5d074de3 100755 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .devcontainer/ @Recently-Snapshot/ Documents/ +mariadb/data/ # ===================================================== # IDE/Editor settings # ===================================================== diff --git a/backend/README2.md b/backend/README2.md new file mode 100644 index 00000000..acc24a33 --- /dev/null +++ b/backend/README2.md @@ -0,0 +1,159 @@ +# DMS Backend – คู่มือ Auth & RBAC/ABAC + +## ภาพรวมระบบ +Backend ใช้ **Bearer Token** สำหรับการยืนยันตัวตน และตรวจสอบสิทธิ์ด้วย **RBAC (Role-Based Access Control)** ร่วมกับ **ABAC (Attribute-Based Access Control)** + +โครงหลักคือ: +1. **authJwt()** → ตรวจสอบ JWT ใน header `Authorization: Bearer ...` +2. **loadPrincipalMw()** → โหลดข้อมูลผู้ใช้ + บทบาท + สิทธิ์ + ขอบเขตโปรเจ็ค/องค์กร +3. **requirePerm()** → ตรวจสอบ `perm_code` จากตาราง `permissions` และบังคับ ABAC (ORG/PROJECT scope) + +--- + +## การยืนยันตัวตน (Authentication) + +### Frontend ส่งอย่างไร +```http +GET /api/projects +Authorization: Bearer +``` + +- **ไม่มีการใช้ cookie** (Bearer-only) +- ถ้า token หมดอายุ ให้ใช้ `refresh_token` ไปขอใหม่ที่ `/api/auth/refresh` + +### Middleware `authJwt()` +- อ่าน `Authorization: Bearer ...` +- ตรวจสอบด้วย `JWT_SECRET` +- เติม `req.auth = { user_id, username }` + +--- + +## การโหลด Principal + +### Middleware `loadPrincipalMw()` +- ใช้ `user_id` ไป query DB: + - users, roles, permissions, project_ids, org_ids +- สร้าง `req.principal`: +```js +{ + user_id, username, email, first_name, last_name, org_id, + roles: [{ role_id, role_code, role_name }], + permissions: Set, + project_ids: [..], + org_ids: [..], + is_superadmin: true/false, + + // helper functions + can(code), + canAny(codes[]), + canAll(codes[]), + inProject(pid), + inOrg(oid) +} +``` + +--- + +## การตรวจสอบสิทธิ์ (RBAC + ABAC) + +### Middleware `requirePerm(permCode, { projectParam?, orgParam? })` +1. ตรวจว่า user มี `permCode` หรือเป็น superadmin +2. อ่าน `scope_level` จากตาราง `permissions` + - `GLOBAL` → มีสิทธิ์ก็พอ + - `ORG` → ต้องมีสิทธิ์ + อยู่ใน org scope + - `PROJECT` → ต้องมีสิทธิ์ + อยู่ใน project scope +3. อ่าน `project_id` / `org_id` จาก request (`params`, `query`, `body`) +4. ถ้าไม่ผ่าน → คืน `403 FORBIDDEN` + +### Error response ตัวอย่าง +```json +{ "error": "FORBIDDEN", "need": "projects.manage" } +{ "error": "FORBIDDEN_PROJECT", "project_id": 12 } +{ "error": "FORBIDDEN_ORG", "org_id": 5 } +``` + +--- + +## รูปแบบที่แนะนำ + +### List (PROJECT scope) +```js +r.get("/", requirePerm("documents.view", { projectParam: "project_id" }), async (req, res) => { + const P = req.principal; + const { project_id } = req.query; + const cond = [], params = []; + + if (!P.is_superadmin) { + if (project_id) { + if (!P.inProject(+project_id)) return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + cond.push("project_id=?"); params.push(+project_id); + } else if (P.project_ids?.length) { + cond.push(`project_id IN (${P.project_ids.map(()=>"?").join(",")})`); + params.push(...P.project_ids); + } + } + + const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; + const [rows] = await sql.query(`SELECT * FROM documents ${where} ORDER BY created_at DESC LIMIT 50`, params); + res.json(rows); +}); +``` + +### Item (PROJECT scope) +```js +r.delete("/:id", requirePerm("drawings.delete"), async (req, res) => { + const id = +req.params.id; + const [[row]] = await sql.query("SELECT project_id FROM drawings WHERE id=?", [id]); + if (!row) return res.status(404).json({ error: "Not found" }); + if (!req.principal.is_superadmin && !req.principal.inProject(row.project_id)) { + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + } + await sql.query("DELETE FROM drawings WHERE id=?", [id]); + res.json({ ok: 1 }); +}); +``` + +--- + +## การแมปสิทธิ์ (perm_code) + +| หมวด | สิทธิ์ (perm_code) | scope | +|--------------|---------------------------------------------|------------| +| Organizations| `organizations.view`, `organizations.manage`| GLOBAL | +| Projects | `projects.view`, `projects.manage` | ORG | +| Drawings | `drawings.view`, `drawings.upload`, `drawings.delete` | PROJECT | +| Documents | `documents.view`, `documents.manage` | PROJECT | +| RFAs | `rfas.view`, `rfas.create`, `rfas.respond`, `rfas.delete` | PROJECT | +| Correspondences | `corr.view`, `corr.manage` | PROJECT | +| Transmittals | `transmittals.manage` | PROJECT | +| Reports | `reports.view` | GLOBAL | +| Settings | `settings.manage` | GLOBAL | +| Admin | `admin.access` | ORG | + +--- + +## Checklist สำหรับเพิ่ม Endpoint ใหม่ + +1. เลือก `perm_code` ที่ตรงกับ seed +2. ใส่ `requirePerm("", { projectParam?: "...", orgParam?: "..." })` +3. ถ้าเป็น GET/PUT/DELETE record เดี่ยว → ตรวจสอบซ้ำด้วย `inProject`/`inOrg` +4. ใช้ `callProc("sp_name", [...])` ถ้า endpoint เรียก Stored Procedure +5. ฝั่ง FE ต้องส่ง `Authorization: Bearer ...` และ parameter `project_id`/`org_id` ที่จำเป็น + +--- + +## Pitfalls ที่พบบ่อย +- ลืมส่ง `project_id` ในคำขอ → 403 +- อ้าง perm_code ผิด (เช่น `document.view` แทน `documents.view`) +- ไม่กรอง project/org scope ใน query → ข้อมูลรั่ว +- ลืมเช็ค item-level ABAC → ข้ามขอบเขตได้ +- ปน cookie-auth เข้ามา → backend จะไม่รองรับแล้ว + +--- + +## TL;DR +- ทุกคำขอ → Bearer Token +- `authJwt()` → ใส่ `req.auth` +- `loadPrincipalMw()` → ใส่ `req.principal` (roles, perms, scope) +- `requirePerm()` → บังคับ RBAC + ABAC อัตโนมัติ +- เพิ่ม endpoint ใหม่ → ใช้ checklist ข้างบน diff --git a/backend/src/config.js b/backend/src/config.js index 8abdd13f..bdf58379 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -1,39 +1,45 @@ -// FILE: src/config.js -// Purpose: Centralized configuration for the backend application -// - Loads settings from environment variables with sensible defaults -// - Exports a config object for use throughout the application -// ============================================================= -// Load environment variables from .env file if present -// (uncomment the next line if using dotenv) -// import dotenv from 'dotenv'; dotenv.config(); -// (Make sure to install dotenv package if using this line) -// ============================================================= +// FILE: backend/src/config.js +// Centralized configuration (ESM) + +const toInt = (v, d) => { + const n = Number(v); + return Number.isFinite(n) ? n : d; +}; +const parseAllowlist = (s) => + String(s || "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean); export const config = { - PORT: Number(process.env.BACKEND_PORT || 3001), + PORT: toInt(process.env.PORT ?? process.env.BACKEND_PORT, 3001), + DB: { HOST: process.env.DB_HOST || "mariadb", - PORT: Number(process.env.DB_PORT || 3306), + PORT: toInt(process.env.DB_PORT, 3306), USER: process.env.DB_USER || "center", PASS: process.env.DB_PASSWORD || "Center#2025", NAME: process.env.DB_NAME || "dms", }, + JWT: { - SECRET: - process.env.JWT_SECRET || - "8b0df02e4aee9f9f79a4f2d8ba77b0b82c1ee3446b68cb0bae94ab54d60f8d9e", + SECRET: process.env.JWT_SECRET || "dev-secret", EXPIRES_IN: process.env.JWT_EXPIRES_IN || "8h", - REFRESH_SECRET: - process.env.JWT_REFRESH_SECRET || - "31r2Wp59E8JukXjYiMopzoHDxJBLVefjR9YOOHjk62R5PbFVXzbx2Wjyc9weVDgK", + REFRESH_SECRET: process.env.JWT_REFRESH_SECRET || "dev-refresh", REFRESH_EXPIRES_IN: process.env.JWT_REFRESH_EXPIRES_IN || "7d", }, + SECURITY: { - RATE_LIMIT_WINDOW_MS: Number(process.env.RATE_LIMIT_WINDOW_MS || 60_000), - RATE_LIMIT_MAX: Number(process.env.RATE_LIMIT_MAX || 100), + RATE_LIMIT_WINDOW_MS: toInt(process.env.RATE_LIMIT_WINDOW_MS, 60_000), + RATE_LIMIT_MAX: toInt(process.env.RATE_LIMIT_MAX, 100), }, - CORS_ORIGINS: (process.env.CORS_ALLOWLIST || "") - .split(",") - .map((s) => s.trim()) - .filter(Boolean), + + CORS_ORIGINS: parseAllowlist( + process.env.CORS_ALLOWLIST || + // เผื่อ dev ทั่วไป + "http://localhost:3000,http://127.0.0.1:3000" + ), }; + +// เผื่อไฟล์ไหน import แบบ default +export default config; diff --git a/backend/src/config/permissions.js b/backend/src/config/permissions.js index b95607aa..269cfb69 100644 --- a/backend/src/config/permissions.js +++ b/backend/src/config/permissions.js @@ -1,156 +1,60 @@ // FILE: src/config/permissions.js -// Purpose: Map permission_code to your seed naming convention. -// - Choose one PROFILE (by SEED_PROFILE env) or edit inline to match exactly -// what's in 06_dms_v0_5_0_data_v5_1_seed_users.sql -// ============================================================= -// Two built-in profiles: -// - V5_DOT (default): permission codes with dot notation (e.g. rfa.create) -// - V5_SNAKE: permission codes with snake_case (e.g. rfa_create) -// ============================================================= -// You can also create your own profile by editing below or -// setting SEED_PROFILE env variable to your custom profile name -// and adding your custom profile object here. -// ============================================================= -// Note: Changing permission codes after users/roles/permissions have been created -// will require updating the database records accordingly. -// ============================================================= -// Example: to use V5_SNAKE profile, set environment variable: -// SEED_PROFILE=V5_SNAKE -// ============================================================= -// Example: to use custom profile, set environment variable: -// SEED_PROFILE=MY_CUSTOM_PROFILE -// and add your custom profile object below. -// ============================================================= +// Reference enum สำหรับฝั่งโค้ด/ฟรอนต์เท่านั้น +// แหล่งจริงของ scope อ่านจาก DB: permissions.scope_level (ผ่าน requirePerm()) -const V5_DOT = { - organization: { read: "organization.read" }, - project: { - read: "project.read", - create: "project.create", - update: "project.update", - delete: "project.delete", +const PERM = { + organizations: { + view: "organizations.view", + manage: "organizations.manage", }, - correspondence: { - read: "correspondence.read", - create: "correspondence.create", - update: "correspondence.update", - delete: "correspondence.delete", - upload: "correspondence.upload", + projects: { + view: "projects.view", + manage: "projects.manage", + partiesManage: "project_parties.manage", }, - rfa: { - read: "rfa.read", - create: "rfa.create", - update: "rfa.update", - delete: "rfa.delete", - upload: "rfa.upload", + drawings: { + view: "drawings.view", + upload: "drawings.upload", + delete: "drawings.delete", }, - drawing: { - read: "drawing.read", - create: "drawing.create", - update: "drawing.update", - delete: "drawing.delete", - upload: "drawing.upload", + documents: { + view: "documents.view", + manage: "documents.manage", }, - transmittal: { - read: "transmittal.read", - create: "transmittal.create", - update: "transmittal.update", - delete: "transmittal.delete", - upload: "transmittal.upload", + materials: { + view: "materials.view", + manage: "materials.manage", }, - contract: { - read: "contract.read", - create: "contract.create", - update: "contract.update", - delete: "contract.delete", + ms: { + view: "ms.view", + manage: "ms.manage", }, - contract_dwg: { - read: "contract_dwg.read", - create: "contract_dwg.create", - update: "contract_dwg.update", - delete: "contract_dwg.delete", + rfas: { + view: "rfas.view", + create: "rfas.create", + respond: "rfas.respond", + delete: "rfas.delete", }, - category: { - read: "category.read", - create: "category.create", - update: "category.update", - delete: "category.delete", + correspondences: { + view: "corr.view", + manage: "corr.manage", }, - volume: { - read: "volume.read", - create: "volume.create", - update: "volume.update", - delete: "volume.delete", + transmittals: { + manage: "transmittals.manage", + }, + circulations: { + manage: "cirs.manage", + }, + admin: { + access: "admin.access", + }, + reports: { + view: "reports.view", + }, + settings: { + manage: "settings.manage", }, - permission: { read: "permission.read" }, - user: { read: "user.read" }, }; -const V5_SNAKE = { - organization: { read: "organization_read" }, - project: { - read: "project_read", - create: "project_create", - update: "project_update", - delete: "project_delete", - }, - correspondence: { - read: "correspondence_read", - create: "correspondence_create", - update: "correspondence_update", - delete: "correspondence_delete", - upload: "correspondence_upload", - }, - rfa: { - read: "rfa_read", - create: "rfa_create", - update: "rfa_update", - delete: "rfa_delete", - upload: "rfa_upload", - }, - drawing: { - read: "drawing_read", - create: "drawing_create", - update: "drawing_update", - delete: "drawing_delete", - upload: "drawing_upload", - }, - transmittal: { - read: "transmittal_read", - create: "transmittal_create", - update: "transmittal_update", - delete: "transmittal_delete", - upload: "transmittal_upload", - }, - contract: { - read: "contract_read", - create: "contract_create", - update: "contract_update", - delete: "contract_delete", - }, - contract_dwg: { - read: "contract_dwg_read", - create: "contract_dwg_create", - update: "contract_dwg_update", - delete: "contract_dwg_delete", - }, - category: { - read: "category_read", - create: "category_create", - update: "category_update", - delete: "category_delete", - }, - volume: { - read: "volume_read", - create: "volume_create", - update: "volume_update", - delete: "volume_delete", - }, - permission: { read: "permission_read" }, - user: { read: "user_read" }, -}; - -const PROFILE = (process.env.SEED_PROFILE || "V5_DOT").toUpperCase(); - -export const PERM = PROFILE === "V5_SNAKE" ? V5_SNAKE : V5_DOT; +export { PERM }; export default PERM; diff --git a/backend/src/db/index.js b/backend/src/db/index.js index b2f1cd24..31392cd1 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -1,17 +1,4 @@ -// FILE: src/db/index.js (ESM) -// Database connection and query utility -// - Uses mysql2/promise for connection pooling and async/await -// - Exports a query function for executing SQL with parameters -// - Connection settings are read from environment variables with defaults -// - Uses named placeholders for query parameters -// - Dates are handled as strings in UTC timezone to avoid timezone issues -// - Connection pool is configured to handle multiple concurrent requests -// ============================================================= -// Load environment variables from .env file if present -// (uncomment the next line if using dotenv) -// import dotenv from 'dotenv'; dotenv.config(); -// (Make sure to install dotenv package if using this line) - +// FILE: backend/src/db/index.js (ESM) import mysql from "mysql2/promise"; const { @@ -30,21 +17,23 @@ const pool = mysql.createPool({ password: DB_PASSWORD, database: DB_NAME, connectionLimit: Number(DB_CONN_LIMIT), - waitForConnections: true, // Recommended for handling connection spikes + waitForConnections: true, namedPlaceholders: true, - dateStrings: true, // Keep dates as strings - timezone: "Z", // Store and retrieve dates in UTC + dateStrings: true, // คงวันที่เป็น string + timezone: "Z", // ใช้ UTC }); /** - * Executes a SQL query with parameters. - * @param {string} sql The SQL query string. - * @param {object} [params={}] The parameters to bind to the query. - * @returns {Promise} A promise that resolves to an array of rows. + * เรียก Stored Procedure แบบง่าย + * @param {string} procName ชื่อโปรซีเยอร์ เช่น "sp_rfa_create_with_items" + * @param {Array} params ลำดับพารามิเตอร์ + * @returns {Promise} rows จาก CALL */ -export async function query(sql, params = {}) { - const [rows] = await pool.execute(sql, params); +export async function callProc(procName, params = []) { + const placeholders = params.map(() => "?").join(","); + const sql = `CALL ${procName}(${placeholders})`; + const [rows] = await pool.query(sql, params); return rows; } -export default pool; +export default pool; // ใช้ sql.query(...) ได้ตามที่ routes เรียกอยู่ diff --git a/backend/src/db/sequelize.js b/backend/src/db/sequelize.js index 59ba51fd..39e16066 100644 --- a/backend/src/db/sequelize.js +++ b/backend/src/db/sequelize.js @@ -1,10 +1,7 @@ -// FILE: src/db/sequelize.js -// Sequelize initialization and model definitions -// - Configured via config.js -// - Defines User, Role, Permission, UserRole, RolePermission models -// - Sets up associations between models -// - Exports sequelize instance and models for use in other modules - +// 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"; @@ -18,49 +15,57 @@ export const sequelize = new Sequelize( dialect: "mariadb", logging: false, dialectOptions: { timezone: "Z" }, - define: { - freezeTableName: true, - underscored: false, - timestamps: false, - }, + define: { freezeTableName: true, underscored: false, timestamps: false }, pool: { max: 10, min: 0, idle: 10000 }, } ); -import UserModel from "./models/User.js"; -import RoleModel from "./models/Role.js"; -import PermissionModel from "./models/Permission.js"; -import UserRoleModel from "./models/UserRole.js"; -import RolePermissionModel from "./models/RolePermission.js"; +export let User = null; +export let Role = null; +export let Permission = null; +export let UserRole = null; +export let RolePermission = null; -export const User = UserModel(sequelize); -export const Role = RoleModel(sequelize); -export const Permission = PermissionModel(sequelize); -export const UserRole = UserRoleModel(sequelize); -export const RolePermission = RolePermissionModel(sequelize); +if (process.env.ENABLE_SEQUELIZE === "1") { + // โหลดโมเดลแบบ on-demand เพื่อลดความเสี่ยง runtime หากไฟล์โมเดลไม่มี + const mdlUser = await import("./models/User.js").catch(() => null); + const mdlRole = await import("./models/Role.js").catch(() => null); + const mdlPerm = await import("./models/Permission.js").catch(() => null); + const mdlUR = await import("./models/UserRole.js").catch(() => null); + const mdlRP = await import("./models/RolePermission.js").catch(() => null); -User.belongsToMany(Role, { - through: UserRole, - foreignKey: "user_id", - otherKey: "role_id", -}); -Role.belongsToMany(User, { - through: UserRole, - foreignKey: "role_id", - otherKey: "user_id", -}); + if (mdlUser?.default) User = mdlUser.default(sequelize); + if (mdlRole?.default) Role = mdlRole.default(sequelize); + if (mdlPerm?.default) Permission = mdlPerm.default(sequelize); + if (mdlUR?.default) UserRole = mdlUR.default(sequelize); + if (mdlRP?.default) RolePermission = mdlRP.default(sequelize); -Role.belongsToMany(Permission, { - through: RolePermission, - foreignKey: "role_id", - otherKey: "permission_id", -}); -Permission.belongsToMany(Role, { - through: RolePermission, - foreignKey: "permission_id", - otherKey: "role_id", -}); + if (User && Role && Permission && UserRole && RolePermission) { + User.belongsToMany(Role, { + through: UserRole, + foreignKey: "user_id", + otherKey: "role_id", + }); + Role.belongsToMany(User, { + through: UserRole, + foreignKey: "role_id", + otherKey: "user_id", + }); + + Role.belongsToMany(Permission, { + through: RolePermission, + foreignKey: "role_id", + otherKey: "permission_id", + }); + Permission.belongsToMany(Role, { + through: RolePermission, + foreignKey: "permission_id", + otherKey: "role_id", + }); + } +} export async function dbReady() { + // โหมดเบา ๆ: แค่ทดสอบเชื่อมต่อ await sequelize.authenticate(); } diff --git a/backend/src/index.js b/backend/src/index.js index f5d2fee1..216e4a00 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,72 +1,6 @@ -// FILE: src/index.js (ESM) -// Main entry point for the backend API server -// - Sets up Express app with middleware, routes, error handling -// - Connects to database -// - Starts server and handles graceful shutdown -// ========================== -// Context: -// - Node.js >= 18 (ESM) -// - Express.js 4/5 -// - MySQL database (using mysql2/promise) -// ========================== -// Features: -// - CORS with dynamic origin checking -// - Cookie parsing -// - JSON and URL-encoded body parsing -// - Access logging -// - Health, livez, readyz, info endpoints -// - JWT authentication middleware -// - Principal loading middleware -// - Modular route handlers for various resources -// - 404 and error handling middleware -// - Graceful shutdown on SIGTERM/SIGINT -// ========================== -// Assumptions: -// - Environment variables for configuration (e.g., PORT, DB connection, FRONTEND_ORIGIN) -// - Database connection module at ./db/index.js -// - Middleware modules for auth, permissions, principal loading -// - Route modules for different API resources -// - Logs directory exists or can be created -// - Code is written in JavaScript (ESM) and runs in Node.js environment -// - Uses ES6+ features for cleaner and more maintainable code -// ========================== -// Notes: -// - Adjust CORS origins as needed for your frontend applications -// - Ensure proper error handling and logging as per your requirements -// - Customize middleware and routes as per your application's needs -// ========================== -// Best Practices Followed: -// - Assumes existence of necessary database tables and columns -// - Assumes existence of necessary middleware and utility functions -// - Assumes Express.js app is set up to use this router for /api path -// - Assumes existence of necessary environment variables -// - Assumes existence of necessary directories and permissions for file storage -// - Assumes multer is installed and configured -// - Assumes fs and path modules are available for file system operations -// - Assumes sql module is set up for database interactions -// - Assumes middleware modules are correctly implemented and exported -// - Assumes route modules are correctly implemented and exported -// - Uses environment variables for configuration -// - Uses middleware for modular functionality -// - Uses async/await for asynchronous operations -// - Uses try/catch for error handling in async functions (if needed) -// - Uses parameterized queries to prevent SQL injection -// - Uses HTTP status codes for responses (e.g., 404 for not found, 400 for bad request) -// - Uses JSON responses for API endpoints -// - Uses destructuring and default parameters for cleaner function signatures -// - Uses best practices for Express.js route handling -// - Uses modular code structure for maintainability -// - Uses comments for documentation and clarity -// - Uses ES6+ features for cleaner and more maintainable code -// - Uses template literals for SQL query construction -// - Uses array methods for filtering and joining conditions -// - Uses utility functions for common tasks (e.g., building SQL WHERE clauses) -// ========================== - +// FILE: backend/src/index.js (ESM) ไฟล์ฉบับ “Bearer-only” import fs from "node:fs"; -import path from "node:path"; import express from "express"; -import cookieParser from "cookie-parser"; import cors from "cors"; import sql from "./db/index.js"; @@ -91,93 +25,87 @@ import uploadsRoutes from "./routes/uploads.js"; import usersRoutes from "./routes/users.js"; import permissionsRoutes from "./routes/permissions.js"; -/* ========================== - * CONFIG - * ========================== */ const PORT = Number(process.env.PORT || 3001); -const NODE_ENV = process.env.NODE_ENV || "production"; +const NODE_ENV = process.env.NODE_ENV || "development"; -// Origin ของ Frontend (ตั้งผ่าน ENV ต่อ environment; dev ใช้ localhost) const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || "https://lcbp3.np-dms.work"; + const ALLOW_ORIGINS = [ "http://localhost:3000", "http://127.0.0.1:3000", FRONTEND_ORIGIN, + ...(process.env.CORS_ALLOWLIST + ? process.env.CORS_ALLOWLIST.split(",") + .map((x) => x.trim()) + .filter(Boolean) + : []), ].filter(Boolean); -// ที่เก็บ log ภายใน container ถูก bind ไปที่ /share/Container/dms/logs/backend const LOG_DIR = process.env.BACKEND_LOG_DIR || "/app/logs"; - -// สร้างโฟลเดอร์ log ถ้ายังไม่มี try { if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); } catch (e) { console.warn("[WARN] Cannot ensure LOG_DIR:", LOG_DIR, e?.message); } -/* ========================== - * APP INIT - * ========================== */ const app = express(); - -// ✅ อยู่หลัง NPM/Reverse proxy → ให้ trust proxy เพื่อให้ cookie secure / proto ทำงานถูก app.set("trust proxy", 1); -// CORS แบบกำหนด origin ตามรายการที่อนุญาต + อนุญาต credentials (จำเป็นสำหรับ cookie) +// CORS: allow list app.use( cors({ origin(origin, cb) { if (!origin) return cb(null, true); // server-to-server / curl - return cb(null, ALLOW_ORIGINS.includes(origin)); + cb(null, ALLOW_ORIGINS.includes(origin)); }, - credentials: true, + credentials: false, // Bearer-only methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"], + allowedHeaders: [ + "Content-Type", + "Authorization", + "X-Requested-With", + "Accept", + "Origin", + "Referer", + "User-Agent", + "Cache-Control", + "Pragma", + ], exposedHeaders: ["Content-Disposition", "Content-Length"], }) ); -// preflight app.options( "*", cors({ origin(origin, cb) { if (!origin) return cb(null, true); - return cb(null, ALLOW_ORIGINS.includes(origin)); + cb(null, ALLOW_ORIGINS.includes(origin)); }, - credentials: true, + credentials: false, }) ); -app.use(cookieParser()); - -// Payload limits app.use(express.json({ limit: "10mb" })); app.use(express.urlencoded({ extended: true, limit: "10mb" })); -// Access log (ขั้นต่ำ) +// minimal access log app.use((req, _res, next) => { console.log(`[REQ] ${req.method} ${req.originalUrl}`); next(); }); -/* ========================== - * HEALTH / READY / INFO - * ========================== */ -app.get("/health", async (req, res) => { +// health/info (เปิดทั้ง /health, /livez, /readyz, /info) +app.get("/health", async (_req, res) => { try { const [[{ now }]] = await sql.query("SELECT NOW() AS now"); - return res.json({ status: "ok", db: "ok", now }); + res.json({ status: "ok", db: "ok", now }); } catch (e) { - return res - .status(500) - .json({ status: "degraded", db: "fail", error: e?.message }); + res.status(500).json({ status: "degraded", db: "fail", error: e?.message }); } }); - -// Kubernetes-style endpoints (ถ้าใช้) -app.get("/livez", (req, res) => res.send("ok")); -app.get("/readyz", async (req, res) => { +app.get("/livez", (_req, res) => res.send("ok")); +app.get("/readyz", async (_req, res) => { try { await sql.query("SELECT 1"); res.send("ready"); @@ -185,26 +113,20 @@ app.get("/readyz", async (req, res) => { res.status(500).send("not-ready"); } }); - -app.get("/info", (req, res) => { +app.get("/info", (_req, res) => res.json({ name: "dms-backend", env: NODE_ENV, version: process.env.APP_VERSION || "0.5.0", commit: process.env.GIT_COMMIT || undefined, - }); -}); + }) +); -/* ========================== - * ROUTES - * ========================== */ -// /api/health (ถอดจาก healthRouter) +// ---------- Public (no auth) ---------- app.use("/api", healthRouter); - -// ✅ auth กลุ่มนี้ "ไม่ต้อง" ผ่าน authJwt app.use("/api/auth", authRoutes); -// จากนี้ไป ทุก /api/* ต้องผ่าน JWT + principal +// ---------- Protected (Bearer + Principal) ---------- app.use("/api", authJwt(), loadPrincipalMw()); app.use("/api/lookup", lookupRoutes); @@ -222,33 +144,22 @@ app.use("/api/uploads", uploadsRoutes); app.use("/api/users", usersRoutes); app.use("/api/permissions", permissionsRoutes); -/* ========================== - * NOT FOUND & ERROR HANDLERS - * ========================== */ -app.use((req, res) => { - res.status(404).json({ error: "NOT_FOUND", path: req.originalUrl }); -}); - +// 404 / error +app.use((req, res) => + res.status(404).json({ error: "NOT_FOUND", path: req.originalUrl }) +); // eslint-disable-next-line no-unused-vars -app.use((err, req, res, _next) => { +app.use((err, _req, res, _next) => { console.error("[UNHANDLED ERROR]", err); - const status = err?.status || 500; - res.status(status).json({ - error: "SERVER_ERROR", - message: NODE_ENV === "production" ? undefined : err?.message, - }); + res.status(err?.status || 500).json({ error: "SERVER_ERROR" }); }); -/* ========================== - * START SERVER - * ========================== */ +// START const server = app.listen(PORT, () => { console.log(`Backend API listening on ${PORT} (env=${NODE_ENV})`); }); -/* ========================== - * GRACEFUL SHUTDOWN - * ========================== */ +// Shutdown async function shutdown(signal) { try { console.log(`[SHUTDOWN] ${signal} received`); diff --git a/backend/src/middleware/authJwt.js b/backend/src/middleware/authJwt.js index a2601381..e58b8914 100644 --- a/backend/src/middleware/authJwt.js +++ b/backend/src/middleware/authJwt.js @@ -10,19 +10,24 @@ // - ไม่ได้เติม roles/permissions (ถ้าต้องการให้ใช้ auth.js แทนหรือร่วมกัน) import jwt from "jsonwebtoken"; -const { JWT_SECRET = "dev-secret" } = process.env; export function authJwt() { + const { JWT_SECRET = "dev-secret" } = process.env; return (req, res, next) => { const h = req.headers.authorization || ""; - const token = h.startsWith("Bearer ") ? h.slice(7) : null; - if (!token) return res.status(401).json({ error: "Unauthenticated" }); + // const token = h.startsWith("Bearer ") ? h.slice(7) : null; + const m = /^Bearer\s+(.+)$/i.exec(h || ""); + //if (!token) return res.status(401).json({ error: "Unauthenticated" }); + if (!m) return res.status(401).json({ error: "Unauthenticated" }); try { - const payload = jwt.verify(token, JWT_SECRET); - req.user = { user_id: payload.user_id, username: payload.username }; + //const payload = jwt.verify(token, JWT_SECRET); + const payload = jwt.verify(m[1], JWT_SECRET, { issuer: "dms-backend" }); + // แนบข้อมูลขั้นต่ำให้ middleware ถัดไป + req.auth = { user_id: payload.user_id, username: payload.username }; + //req.user = { user_id: payload.user_id, username: payload.username }; next(); } catch (e) { - return res.status(401).json({ error: "Invalid token" }); + return res.status(401).json({ error: "Unauthenticated" }); } }; } diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 7729bc9c..031cc906 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -1,36 +1,21 @@ // FILE: src/routes/admin.js -// Admin routes -// - System info (GET /api/admin/sysinfo) -// - Maintenance tasks (POST /api/admin/maintenance/reindex) -// - Permission matrix (GET /api/admin/perm-matrix?format=md|json) -// - Requires appropriate permissions via requirePerm middleware -// - Uses global scope for all permissions -// - admin.read, admin.maintain - import { Router } from "express"; import os from "node:os"; -import { dbReady, sequelize, Role, Permission } from "../db/sequelize.js"; +import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import PERM from "../config/permissions.js"; const r = Router(); -// แนะนำ: ensure DB connection once (กันเผลอเรียกก่อน DB พร้อม) -await dbReady().catch((e) => { - console.error("[admin] DB not ready:", e?.message); -}); - /** * GET /api/admin/sysinfo - * perm: admin.read (global) + * perm: admin.access (ORG scope) – ใช้สิทธิ์กลุ่ม admin */ r.get( "/sysinfo", - requirePerm(PERM.admin.read, { scope: "global" }), + requirePerm("admin.access", { orgParam: "org_id" }), async (_req, res) => { try { - // ping DB เบา ๆ - await sequelize.query("SELECT 1"); + await sql.query("SELECT 1"); res.json({ now: new Date().toISOString(), node: process.version, @@ -53,16 +38,15 @@ r.get( /** * POST /api/admin/maintenance/reindex - * perm: admin.maintain (global) - * หมายเหตุ: ปรับรายชื่อตารางตามโปรเจ็คจริงของคุณ + * perm: settings.manage (GLOBAL) – งานดูแลระบบ */ r.post( "/maintenance/reindex", - requirePerm(PERM.admin.maintain, { scope: "global" }), + requirePerm("settings.manage"), async (_req, res) => { try { - // ตัวอย่าง ใช้ RAW ก็ได้เมื่อเหมาะสม - await sequelize.query("ANALYZE TABLE correspondences, rfas, drawings"); + // ปรับตามตารางจริงของคุณ + await sql.query("ANALYZE TABLE correspondences, rfas, drawings"); res.json({ ok: 1 }); } catch (e) { res.status(500).json({ error: "MAINT_FAIL", message: e?.message }); @@ -71,65 +55,39 @@ r.post( ); /** - * GET /api/admin/perm-matrix?format=md|json - * perm: admin.read (global) - * ดึง Role -> Permissions ด้วย association ของ Sequelize + * GET /api/admin/perm-matrix?format=json + * perm: admin.access (ORG) */ r.get( "/perm-matrix", - requirePerm(PERM.admin.read, { scope: "global" }), - async (req, res, next) => { - try { - const format = String(req.query.format || "md").toLowerCase(); - - const roles = await Role.findAll({ - attributes: ["role_id", "role_code", "role_name"], - include: [ - { - model: Permission, - attributes: ["perm_code"], - through: { attributes: [] }, // ไม่ต้องข้อมูลตาราง join - required: false, - }, - ], - order: [["role_code", "ASC"]], - logging: false, - }); - - if (format === "json") { - const data = roles.map((r) => ({ - role_id: r.role_id, - role_code: r.role_code, - role_name: r.role_name, - perm_codes: (r.Permissions || []).map((p) => p.perm_code).sort(), - })); - return res.json({ roles: data }); - } - - // สร้าง Markdown table - const lines = []; - lines.push(`# Permission Matrix (Role → Permissions)`); - lines.push(`_Generated at: ${new Date().toISOString()}_\n`); - lines.push(`| # | Role Code | Role Name | Permissions |`); - lines.push(`|---:|:---------|:----------|:------------|`); - - roles.forEach((r, idx) => { - const perms = (r.Permissions || []) - .map((p) => p.perm_code) - .sort() - .join(", "); - lines.push( - `| ${idx + 1} | \`${r.role_code}\` | ${ - r.role_name || "" - } | ${perms} |` - ); - }); - - res.setHeader("Content-Type", "text/markdown; charset=utf-8"); - return res.send(lines.join("\n")); - } catch (e) { - next(e); - } + requirePerm("admin.access", { orgParam: "org_id" }), + async (req, res) => { + const format = String(req.query.format || "json").toLowerCase(); + const [roles] = await sql.query( + `SELECT r.role_id, r.role_code, r.role_name, + GROUP_CONCAT(p.perm_code ORDER BY p.perm_code SEPARATOR ', ') AS perm_codes + FROM roles r + LEFT JOIN role_permissions rp ON rp.role_id = r.role_id + LEFT JOIN permissions p ON p.permission_id = rp.permission_id + GROUP BY r.role_id, r.role_code, r.role_name + ORDER BY r.role_code` + ); + if (format === "json") return res.json({ roles }); + // markdown แบบง่าย + const lines = [ + `# Permission Matrix`, + `_Generated at: ${new Date().toISOString()}_`, + `| # | Role Code | Role Name | Permissions |`, + `|---:|:---------|:----------|:------------|`, + ...roles.map( + (r, i) => + `| ${i + 1} | \`${r.role_code}\` | ${r.role_name || ""} | ${ + r.perm_codes || "" + } |` + ), + ]; + res.setHeader("Content-Type", "text/markdown; charset=utf-8"); + res.send(lines.join("\n")); } ); diff --git a/backend/src/routes/auth_extras.js b/backend/src/routes/auth_extras.js index cc7c77cf..89332b99 100644 --- a/backend/src/routes/auth_extras.js +++ b/backend/src/routes/auth_extras.js @@ -1,47 +1,20 @@ // FILE: src/routes/auth_extras.js -// Extra auth-related middleware -// - Simple JWT auth from httpOnly cookie -// - Basic role check middleware (for simple cases, use requirePerm for flexibility) - +// Deprecated for this project (เราใช้ Bearer + authJwt() แล้ว) import jwt from "jsonwebtoken"; - const JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || "dev-access-secret"; - -/** - * ตรวจสอบ access_token จาก httpOnly cookie - * ใช้เป็น middleware กับเส้นทางที่ต้องการป้องกันฝั่ง API (ซ้ำกับ authJwt เดิมได้ แต่ตัวนี้อ่านคุกกี้ตรง ๆ) - */ export function requireAuth(req, res, next) { const token = req.cookies?.access_token; if (!token) return res.status(401).json({ error: "Unauthenticated" }); - try { const payload = jwt.verify(token, JWT_ACCESS_SECRET, { issuer: "dms-backend", }); - req.user = { - user_id: payload.user_id, - username: payload.username, - }; + req.user = { user_id: payload.user_id, username: payload.username }; return next(); - } catch (e) { + } catch { return res.status(401).json({ error: "INVALID_TOKEN" }); } } - -/** - * เฉพาะกรณีในอนาคต: ตรวจบทบาท/สิทธิ์ง่าย ๆ - * ใช้หลัง requireAuth เช่น app.get('/api/admin/xxx', requireAuth, requireRole('Admin'), handler) - */ -export function requireRole(roleName) { - return function (req, res, next) { - // สมมติว่ามี req.principal.roles จาก middleware อื่น (เช่น loadPrincipalMw) - const roles = req.principal?.roles || req.user?.roles || []; - if (!Array.isArray(roles) || !roles.includes(roleName)) { - return res.status(403).json({ error: "FORBIDDEN" }); - } - next(); - }; +export function requireRole(_role) { + return (_req, res, next) => res.status(403).json({ error: "FORBIDDEN" }); } -// หมายเหตุ: ในโปรเจกต์นี้ เราใช้ requirePerm จาก src/middleware/requirePerm.js แทน -// เพราะมีความยืดหยุ่นกว่า (ตรวจสิทธิ์เป็นรายรายการ และมี scope ด้วย) diff --git a/backend/src/routes/categories.js b/backend/src/routes/categories.js index 25c2edbc..047b1756 100644 --- a/backend/src/routes/categories.js +++ b/backend/src/routes/categories.js @@ -1,63 +1,39 @@ // FILE: src/routes/categories.js -// Categories and Subcategories routes -// - CRUD operations for categories and subcategories -// - Requires appropriate permissions via requirePerm middleware -// - Uses global scope for all permissions -// - category:read, category:create, category:update, category:delete -// - Category fields: cat_id (PK), cat_code, cat_name -// - Subcategory fields: sub_cat_id (PK), cat_id (FK), sub_cat_code, sub_cat_name -// - cat_code and sub_cat_code are unique -// - Basic validation: cat_code, cat_name required for category create; sub_cat_code, sub_cat_name, cat_id required for subcategory create - import { Router } from "express"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import PERM from "../config/permissions.js"; const r = Router(); -// Category LIST (global master, not scoped) – still require permission -r.get( - "/categories", - requirePerm(PERM.category.read, { scope: "global" }), - async (req, res) => { - const [rows] = await sql.query( - "SELECT * FROM categories ORDER BY cat_id DESC" - ); - res.json(rows); - } -); - -r.post( - "/categories", - requirePerm(PERM.category.create, { scope: "global" }), - async (req, res) => { - const { cat_code, cat_name } = req.body; - const [rs] = await sql.query( - "INSERT INTO categories (cat_code, cat_name) VALUES (?,?)", - [cat_code, cat_name] - ); - res.json({ cat_id: rs.insertId }); - } -); - -r.put( - "/categories/:id", - requirePerm(PERM.category.update, { scope: "global" }), - async (req, res) => { - const id = Number(req.params.id); - const { cat_name } = req.body; - await sql.query("UPDATE categories SET cat_name=? WHERE cat_id=?", [ - cat_name, - id, - ]); - res.json({ ok: 1 }); - } -); - +// Categories +r.get("/categories", requirePerm("organizations.view"), async (_req, res) => { + const [rows] = await sql.query( + "SELECT * FROM categories ORDER BY cat_id DESC" + ); + res.json(rows); +}); +r.post("/categories", requirePerm("settings.manage"), async (req, res) => { + const { cat_code, cat_name } = req.body || {}; + if (!cat_code || !cat_name) + return res.status(400).json({ error: "cat_code and cat_name required" }); + const [rs] = await sql.query( + "INSERT INTO categories (cat_code, cat_name) VALUES (?,?)", + [cat_code, cat_name] + ); + res.json({ cat_id: rs.insertId }); +}); +r.put("/categories/:id", requirePerm("settings.manage"), async (req, res) => { + const id = Number(req.params.id); + const { cat_name } = req.body || {}; + await sql.query("UPDATE categories SET cat_name=? WHERE cat_id=?", [ + cat_name, + id, + ]); + res.json({ ok: 1 }); +}); r.delete( "/categories/:id", - requirePerm(PERM.category.delete, { scope: "global" }), + requirePerm("settings.manage"), async (req, res) => { const id = Number(req.params.id); await sql.query("DELETE FROM categories WHERE cat_id=?", [id]); @@ -65,22 +41,20 @@ r.delete( } ); -// Subcategories (belong to categories) -r.get( - "/subcategories", - requirePerm(PERM.category.read, { scope: "global" }), - async (req, res) => { - const { cat_id } = req.query; - let sqlText = "SELECT * FROM subcategories"; - const params = []; - if (cat_id) { - sqlText += " WHERE cat_id=?"; - params.push(Number(cat_id)); - } - sqlText += " ORDER BY sub_cat_id DESC"; - const [rows] = await sql.query(sqlText, params); - res.json(rows); +// Subcategories +r.get("/subcategories", requirePerm("organizations.view"), async (req, res) => { + const { cat_id } = req.query; + const params = []; + let where = ""; + if (cat_id) { + where = " WHERE cat_id=?"; + params.push(Number(cat_id)); } -); + const [rows] = await sql.query( + `SELECT * FROM subcategories${where} ORDER BY sub_cat_id DESC`, + params + ); + res.json(rows); +}); export default r; diff --git a/backend/src/routes/contract_dwg.js b/backend/src/routes/contract_dwg.js index aaf542a4..4c463590 100644 --- a/backend/src/routes/contract_dwg.js +++ b/backend/src/routes/contract_dwg.js @@ -1,85 +1,73 @@ // FILE: src/routes/contract_dwg.js -// Contract Drawings routes -// - CRUD operations for contract drawings -// - Requires appropriate permissions via requirePerm middleware -// - Uses scope-based access control (global, org, project) via requirePerm -// - contract_dwg:read, contract_dwg:create, contract_dwg:update, contract_dwg:delete -// - contract_dwg fields: id (PK), org_id, project_id, condwg_no, title, drawing_id, volume_id, sub_cat_id, sub_no, remark, created_by -// - Basic filtering on list endpoint by project_id, org_id, condwg_no - import { Router } from "express"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import { buildScopeWhere, ownerResolvers } from "../utils/scope.js"; -import PERM from "../config/permissions.js"; const r = Router(); -const OWN = ownerResolvers(sql, "contract_dwg", "id"); -// LIST mappings +// LIST (PROJECT scope enforce via params ifส่ง project_id, ไม่ส่งจะถูก filter ด้วย scope ภายใน) r.get( "/", - requirePerm(PERM.contract_dwg.read, { scope: "global" }), + requirePerm("drawings.view", { projectParam: "project_id" }), async (req, res) => { const { project_id, org_id, condwg_no, limit = 50, offset = 0 } = req.query; - const base = buildScopeWhere(req.principal, { - tableAlias: "m", - orgColumn: "m.org_id", - projectColumn: "m.project_id", - permCode: PERM.contract_dwg.read, - preferProject: true, - }); - const extra = []; - const params = { - ...base.params, - limit: Number(limit), - offset: Number(offset), - }; - if (project_id) { - extra.push("m.project_id = :project_id"); - params.project_id = Number(project_id); + const p = req.principal; + const params = []; + const cond = []; + + // ABAC filter ฝั่ง server กันหลุดขอบเขต + if (!p.is_superadmin) { + if (project_id) { + if (!p.inProject(Number(project_id))) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + cond.push("m.project_id=?"); + params.push(Number(project_id)); + } else if (p.project_ids?.length) { + cond.push( + `m.project_id IN (${p.project_ids.map(() => "?").join(",")})` + ); + params.push(...p.project_ids); + } + } else if (project_id) { + cond.push("m.project_id=?"); + params.push(Number(project_id)); } + if (org_id) { - extra.push("m.org_id = :org_id"); - params.org_id = Number(org_id); + cond.push("m.org_id=?"); + params.push(Number(org_id)); } if (condwg_no) { - extra.push("m.condwg_no = :condwg_no"); - params.condwg_no = condwg_no; + cond.push("m.condwg_no=?"); + params.push(condwg_no); } - const where = [base.where, ...extra].filter(Boolean).join(" AND "); + + const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; const [rows] = await sql.query( - `SELECT m.* FROM contract_dwg m WHERE ${where} ORDER BY m.id DESC LIMIT :limit OFFSET :offset`, - params + `SELECT m.* FROM contract_dwg m ${where} ORDER BY m.id DESC LIMIT ? OFFSET ?`, + [...params, Number(limit), Number(offset)] ); res.json(rows); } ); -// GET mapping -r.get( - "/:id", - requirePerm(PERM.contract_dwg.read, { - scope: "org", - getOrgId: OWN.getOrgIdById, - }), - async (req, res) => { - const id = Number(req.params.id); - const [[row]] = await sql.query("SELECT * FROM contract_dwg WHERE id=?", [ - id, - ]); - if (!row) return res.status(404).json({ error: "Not found" }); - res.json(row); - } -); +// GET item (ตรวจ ABAC หลังอ่านเรคคอร์ด) +r.get("/:id", requirePerm("drawings.view"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM contract_dwg WHERE id=?", [ + id, + ]); + if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + res.json(row); +}); -// CREATE mapping (1 drawing per contract or per rule) +// CREATE r.post( "/", - requirePerm(PERM.contract_dwg.create, { - scope: "org", - getOrgId: async (req) => req.body?.org_id ?? null, - }), + requirePerm("drawings.upload", { projectParam: "project_id" }), async (req, res) => { const { org_id, @@ -91,20 +79,25 @@ r.post( sub_cat_id, sub_no, remark, - } = req.body; + } = req.body || {}; + if (!project_id || !condwg_no) + return res + .status(400) + .json({ error: "project_id and condwg_no required" }); const [rs] = await sql.query( - `INSERT INTO contract_dwg (org_id, project_id, condwg_no, title, drawing_id, volume_id, sub_cat_id, sub_no, remark, created_by) VALUES (?,?,?,?,?,?,?,?,?,?)`, + `INSERT INTO contract_dwg (org_id, project_id, condwg_no, title, drawing_id, volume_id, sub_cat_id, sub_no, remark, created_by) + VALUES (?,?,?,?,?,?,?,?,?,?)`, [ - org_id, + org_id || null, project_id, condwg_no, - title, - drawing_id, - volume_id, - sub_cat_id, - sub_no, - remark, - req.principal.userId, + title || null, + drawing_id || null, + volume_id || null, + sub_cat_id || null, + sub_no || null, + remark || null, + req.principal.user_id, ] ); res.json({ id: rs.insertId }); @@ -112,36 +105,37 @@ r.post( ); // UPDATE -r.put( - "/:id", - requirePerm(PERM.contract_dwg.update, { - scope: "org", - getOrgId: OWN.getOrgIdById, - }), - async (req, res) => { - const id = Number(req.params.id); - const { title, remark } = req.body; - await sql.query("UPDATE contract_dwg SET title=?, remark=? WHERE id=?", [ - title, - remark, - id, - ]); - res.json({ ok: 1 }); - } -); +r.put("/:id", requirePerm("drawings.upload"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM contract_dwg WHERE id=?", [ + id, + ]); + if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + + const { title, remark } = req.body || {}; + await sql.query("UPDATE contract_dwg SET title=?, remark=? WHERE id=?", [ + title ?? row.title, + remark ?? row.remark, + id, + ]); + res.json({ ok: 1 }); +}); // DELETE -r.delete( - "/:id", - requirePerm(PERM.contract_dwg.delete, { - scope: "org", - getOrgId: OWN.getOrgIdById, - }), - async (req, res) => { - const id = Number(req.params.id); - await sql.query("DELETE FROM contract_dwg WHERE id=?", [id]); - res.json({ ok: 1 }); - } -); +r.delete("/:id", requirePerm("drawings.delete"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM contract_dwg WHERE id=?", [ + id, + ]); + if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + await sql.query("DELETE FROM contract_dwg WHERE id=?", [id]); + res.json({ ok: 1 }); +}); export default r; diff --git a/backend/src/routes/contracts.js b/backend/src/routes/contracts.js index 30071d92..0a982d7d 100644 --- a/backend/src/routes/contracts.js +++ b/backend/src/routes/contracts.js @@ -1,27 +1,14 @@ // FILE: src/routes/contracts.js -// Contracts routes -// - CRUD operations for contracts -// - Requires appropriate permissions via requirePerm middleware -// - Uses org scope for all permissions -// - contract.read, contract.create, contract.update, contract.delete -// - Contract fields: id (PK), org_id, project_id, contract_no, title, status, created_by -// - Basic filtering on list endpoint by project_id, org_id, contract_no -// - Uses async/await for asynchronous operations -// - Middleware functions are used for permission checks -// - Owner resolvers are used to fetch org_id for specific contract ids - import { Router } from "express"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import { buildScopeWhere, ownerResolvers } from "../utils/scope.js"; -import PERM from "../config/permissions.js"; const r = Router(); -const OWN = ownerResolvers(sql, "contracts", "id"); +// LIST r.get( "/", - requirePerm(PERM.contract.read, { scope: "global" }), + requirePerm("projects.view", { orgParam: "org_id" }), async (req, res) => { const { project_id, @@ -31,97 +18,118 @@ r.get( limit = 50, offset = 0, } = req.query; - const base = buildScopeWhere(req.principal, { - tableAlias: "c", - orgColumn: "c.org_id", - projectColumn: "c.project_id", - permCode: PERM.contract.read, - preferProject: true, - }); - const extra = []; - const params = { - ...base.params, - limit: Number(limit), - offset: Number(offset), - }; - if (project_id) { - extra.push("c.project_id = :project_id"); - params.project_id = Number(project_id); + const p = req.principal; + const params = []; + const cond = []; + if (!p.is_superadmin) { + if (org_id) { + if (!p.inOrg(Number(org_id))) + return res.status(403).json({ error: "FORBIDDEN_ORG" }); + cond.push("c.org_id=?"); + params.push(Number(org_id)); + } else if (p.org_ids?.length) { + cond.push(`c.org_id IN (${p.org_ids.map(() => "?").join(",")})`); + params.push(...p.org_ids); + } + } else if (org_id) { + cond.push("c.org_id=?"); + params.push(Number(org_id)); } - if (org_id) { - extra.push("c.org_id = :org_id"); - params.org_id = Number(org_id); + + if (project_id) { + cond.push("c.project_id=?"); + params.push(Number(project_id)); } if (contract_no) { - extra.push("c.contract_no = :contract_no"); - params.contract_no = contract_no; + cond.push("c.contract_no=?"); + params.push(contract_no); } if (q) { - extra.push("(c.contract_no LIKE :q OR c.title LIKE :q)"); - params.q = `%${q}%`; + cond.push("(c.contract_no LIKE ? OR c.title LIKE ?)"); + params.push(`%${q}%`, `%${q}%`); } - const where = [base.where, ...extra].filter(Boolean).join(" AND "); + + const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; const [rows] = await sql.query( - `SELECT c.* FROM contracts c WHERE ${where} ORDER BY c.id DESC LIMIT :limit OFFSET :offset`, - params + `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(PERM.contract.read, { scope: "org", getOrgId: OWN.getOrgIdById }), + requirePerm("projects.view", { orgParam: "org_id" }), async (req, res) => { const id = Number(req.params.id); const [[row]] = await sql.query("SELECT * FROM contracts WHERE id=?", [id]); if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inOrg(row.org_id)) + return res.status(403).json({ error: "FORBIDDEN_ORG" }); res.json(row); } ); +// CREATE r.post( "/", - requirePerm(PERM.contract.create, { - scope: "org", - getOrgId: async (req) => req.body?.org_id ?? null, - }), + requirePerm("projects.manage", { orgParam: "org_id" }), async (req, res) => { - const { org_id, project_id, contract_no, title, status } = req.body; + const { org_id, project_id, contract_no, title, status } = req.body || {}; + if (!org_id || !project_id || !contract_no) + return res + .status(400) + .json({ error: "org_id, project_id, contract_no required" }); const [rs] = await sql.query( `INSERT INTO contracts (org_id, project_id, contract_no, title, status, created_by) VALUES (?,?,?,?,?,?)`, - [org_id, project_id, contract_no, title, status, req.principal.userId] + [ + org_id, + project_id, + contract_no, + title || null, + status || null, + req.principal.user_id, + ] ); res.json({ id: rs.insertId }); } ); +// UPDATE r.put( "/:id", - requirePerm(PERM.contract.update, { - scope: "org", - getOrgId: OWN.getOrgIdById, - }), + requirePerm("projects.manage", { orgParam: "org_id" }), async (req, res) => { const id = Number(req.params.id); - const { title, status } = req.body; + const [[row]] = await sql.query("SELECT * FROM contracts WHERE id=?", [id]); + if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inOrg(row.org_id)) + return res.status(403).json({ error: "FORBIDDEN_ORG" }); + const { title, status } = req.body || {}; await sql.query("UPDATE contracts SET title=?, status=? WHERE id=?", [ - title, - status, + title ?? row.title, + status ?? row.status, id, ]); res.json({ ok: 1 }); } ); +// DELETE r.delete( "/:id", - requirePerm(PERM.contract.delete, { - scope: "org", - getOrgId: OWN.getOrgIdById, - }), + requirePerm("projects.manage", { orgParam: "org_id" }), async (req, res) => { const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM contracts WHERE id=?", [id]); + if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inOrg(row.org_id)) + return res.status(403).json({ error: "FORBIDDEN_ORG" }); await sql.query("DELETE FROM contracts WHERE id=?", [id]); res.json({ ok: 1 }); } diff --git a/backend/src/routes/correspondences.js b/backend/src/routes/correspondences.js index e3d7a8ed..b760e46b 100644 --- a/backend/src/routes/correspondences.js +++ b/backend/src/routes/correspondences.js @@ -1,124 +1,124 @@ -// FILE: src/routes/correspondences.js -// 03.2 7) เพิ่ม routes/correspondences.js (ใหม่) -// - ใช้ร่วมกับ requirePerm() และ buildScopeWhere() -// - สำหรับจัดการ correspondences (ดู/เพิ่ม/แก้ไข/ลบ) ตามสิทธิ์ของผู้ใช้ -// Correspondences routes -// - CRUD operations for correspondences -// - Requires appropriate permissions via requirePerm middleware -// - Uses org scope for all permissions -// - correspondence:read, correspondence:create, correspondence:update, correspondence:delete -// - Correspondence fields: id (PK), org_id, project_id, corr_no, subject, status, created_by -// - Basic validation: org_id, corr_no, subject required for create - +// FILE: backend/src/routes/correspondences.js import { Router } from "express"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import { buildScopeWhere, ownerResolvers } from "../utils/scope.js"; -import PERM from "../config/permissions.js"; const r = Router(); -const OWN = ownerResolvers(sql, "correspondences", "id"); +// LIST r.get( "/", - requirePerm(PERM.correspondence.read, { scope: "global" }), + requirePerm("corr.view", { projectParam: "project_id" }), async (req, res) => { const { project_id, org_id, q, limit = 50, offset = 0 } = req.query; - const base = buildScopeWhere(req.principal, { - tableAlias: "c", - orgColumn: "c.org_id", - projectColumn: "c.project_id", - permCode: PERM.correspondence.read, - preferProject: true, - }); - const extra = []; - const params = { - ...base.params, - limit: Number(limit), - offset: Number(offset), - }; - if (project_id) { - extra.push("c.project_id = :project_id"); - params.project_id = Number(project_id); + const p = req.principal; + const params = []; + const cond = []; + + if (!p.is_superadmin) { + if (project_id) { + if (!p.inProject(Number(project_id))) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + cond.push("c.project_id=?"); + params.push(Number(project_id)); + } else if (p.project_ids?.length) { + cond.push( + `c.project_id IN (${p.project_ids.map(() => "?").join(",")})` + ); + params.push(...p.project_ids); + } + } else if (project_id) { + cond.push("c.project_id=?"); + params.push(Number(project_id)); } + if (org_id) { - extra.push("c.org_id = :org_id"); - params.org_id = Number(org_id); + cond.push("c.org_id=?"); + params.push(Number(org_id)); } if (q) { - extra.push("(c.corr_no LIKE :q OR c.subject LIKE :q)"); - params.q = `%${q}%`; + cond.push("(c.corr_no LIKE ? OR c.subject LIKE ?)"); + params.push(`%${q}%`, `%${q}%`); } - const where = [base.where, ...extra].join(" AND "); + + const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; const [rows] = await sql.query( - `SELECT c.* FROM correspondences c WHERE ${where} ORDER BY c.id DESC LIMIT :limit OFFSET :offset`, - params + `SELECT c.* FROM correspondences c ${where} ORDER BY c.id DESC LIMIT ? OFFSET ?`, + [...params, Number(limit), Number(offset)] ); res.json(rows); } ); -r.get( - "/:id", - requirePerm(PERM.correspondence.read, { - scope: "org", - getOrgId: OWN.getOrgIdById, - }), - async (req, res) => { - const id = Number(req.params.id); - const [[row]] = await sql.query( - "SELECT * FROM correspondences WHERE id=?", - [id] - ); - if (!row) return res.status(404).json({ error: "Not found" }); - res.json(row); - } -); +// GET +r.get("/:id", requirePerm("corr.view"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM correspondences WHERE id=?", [ + id, + ]); + if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + res.json(row); +}); +// CREATE r.post( "/", - requirePerm(PERM.correspondence.create, { - scope: "org", - getOrgId: async (req) => req.body?.org_id ?? null, - }), + requirePerm("corr.manage", { projectParam: "project_id" }), async (req, res) => { - const { org_id, project_id, corr_no, subject, status } = req.body; + const { org_id, project_id, corr_no, subject, status } = req.body || {}; + if (!project_id || !corr_no) + return res.status(400).json({ error: "project_id and corr_no required" }); const [rs] = await sql.query( - `INSERT INTO correspondences (org_id, project_id, corr_no, subject, status, created_by) VALUES (?,?,?,?,?,?)`, - [org_id, project_id, corr_no, subject, status, req.principal.userId] + `INSERT INTO correspondences (org_id, project_id, corr_no, subject, status, created_by) + VALUES (?,?,?,?,?,?)`, + [ + org_id || null, + project_id, + corr_no, + subject || null, + status || null, + req.principal.user_id, + ] ); res.json({ id: rs.insertId }); } ); -r.put( - "/:id", - requirePerm(PERM.correspondence.update, { - scope: "org", - getOrgId: OWN.getOrgIdById, - }), - async (req, res) => { - const id = Number(req.params.id); - const { subject, status } = req.body; - await sql.query( - "UPDATE correspondences SET subject=?, status=? WHERE id=?", - [subject, status, id] - ); - res.json({ ok: 1 }); - } -); +// UPDATE +r.put("/:id", requirePerm("corr.manage"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM correspondences WHERE id=?", [ + id, + ]); + if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); -r.delete( - "/:id", - requirePerm(PERM.correspondence.delete, { - scope: "org", - getOrgId: OWN.getOrgIdById, - }), - async (req, res) => { - const id = Number(req.params.id); - await sql.query("DELETE FROM correspondences WHERE id=?", [id]); - res.json({ ok: 1 }); - } -); + const { subject, status } = req.body || {}; + await sql.query("UPDATE correspondences SET subject=?, status=? WHERE id=?", [ + subject ?? row.subject, + status ?? row.status, + id, + ]); + res.json({ ok: 1 }); +}); + +// DELETE +r.delete("/:id", requirePerm("corr.manage"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM correspondences WHERE id=?", [ + id, + ]); + if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + await sql.query("DELETE FROM correspondences WHERE id=?", [id]); + res.json({ ok: 1 }); +}); export default r; diff --git a/backend/src/routes/documents.js b/backend/src/routes/documents.js index 9d0ff418..95924775 100644 --- a/backend/src/routes/documents.js +++ b/backend/src/routes/documents.js @@ -1,67 +1,149 @@ -// FILE: src/routes/documents.js -// Documents routes -// - CRUD operations for documents -// - Requires appropriate permissions via requirePerm middleware -// - Uses project scope for all permissions -// - document:read, document:create, document:update, document:delete -// - Document fields: document_id (PK), project_id, doc_no, title, category, status, created_by, updated_by -// - Basic validation: project_id and doc_no required for create - -import { Router } from 'express'; -import { requireAuth } from '../middleware/auth.js'; -import { enrichPermissions } from '../middleware/permissions.js'; -import { requireRole } from '../middleware/rbac.js'; -import { requirePerm } from '../middleware/permGuard.js'; -import { sequelize } from '../db/sequelize.js'; -import DocumentModel from '../db/models/Document.js'; +// FILE: backend/src/routes/documents.js +import { Router } from "express"; +import sql from "../db/index.js"; +import { requirePerm } from "../middleware/requirePerm.js"; const r = Router(); -const Doc = DocumentModel(sequelize); -r.get('/documents', requireAuth, async (req, res) => { - const { q, project_id, status, category, page=1, page_size=20 } = req.query; - const limit = Math.min(Number(page_size)||20, 100); - const offset = (Math.max(Number(page)||1,1)-1) * limit; +// LIST +r.get( + "/", + requirePerm("documents.view", { projectParam: "project_id" }), + async (req, res) => { + const { + q, + project_id, + status, + category, + page = 1, + page_size = 20, + } = req.query; + const limit = Math.min(Number(page_size) || 20, 100); + const offset = (Math.max(Number(page) || 1, 1) - 1) * limit; - const where = {}; - if (project_id) where.project_id = project_id; - if (status) where.status = status; - if (category) where.category = category; - if (q) where.title = sequelize.where(sequelize.fn('LOWER', sequelize.col('title')), 'LIKE', `%${String(q).toLowerCase()}%`); + const p = req.principal; + const params = []; + const cond = []; - const { rows, count } = await Doc.findAndCountAll({ where, limit, offset, order:[['created_at','DESC']] }); - res.json({ items: rows, total: count, page: Number(page), page_size: limit }); -}); + if (!p.is_superadmin) { + if (project_id) { + if (!p.inProject(Number(project_id))) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + cond.push("d.project_id=?"); + params.push(Number(project_id)); + } else if (p.project_ids?.length) { + cond.push( + `d.project_id IN (${p.project_ids.map(() => "?").join(",")})` + ); + params.push(...p.project_ids); + } + } else if (project_id) { + cond.push("d.project_id=?"); + params.push(Number(project_id)); + } -r.get('/documents/:id', requireAuth, async (req, res) => { - const row = await Doc.findByPk(Number(req.params.id)); - if (!row) return res.status(404).json({ error: 'Not found' }); + if (status) { + cond.push("d.status=?"); + params.push(status); + } + if (category) { + cond.push("d.category=?"); + params.push(category); + } + if (q) { + cond.push("(LOWER(d.title) LIKE ? OR d.doc_no LIKE ?)"); + params.push(`%${String(q).toLowerCase()}%`, `%${q}%`); + } + + const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; + const [[{ cnt }]] = await sql.query( + `SELECT COUNT(*) AS cnt FROM documents d ${where}`, + params + ); + const [rows] = await sql.query( + `SELECT d.* FROM documents d ${where} ORDER BY d.created_at DESC LIMIT ? OFFSET ?`, + [...params, limit, offset] + ); + res.json({ items: rows, total: cnt, page: Number(page), page_size: limit }); + } +); + +// GET +r.get("/:id", requirePerm("documents.view"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query( + "SELECT * FROM documents WHERE document_id=?", + [id] + ); + if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); res.json(row); }); -r.post('/documents', requireAuth, enrichPermissions(), requireProjectMembershipFromBody(), enrichPermissions(), requirePerm('document:create'), async (req, res) => { - const { project_id, doc_no, title, category, status } = req.body || {}; - if (!project_id || !doc_no) return res.status(400).json({ error: 'project_id and doc_no required' }); - const created = await Doc.create({ project_id, doc_no, title, category, status, created_by: req.user?.user_id }); - res.status(201).json({ document_id: created.document_id }); -}); +// CREATE +r.post( + "/", + requirePerm("documents.manage", { projectParam: "project_id" }), + async (req, res) => { + const { project_id, doc_no, title, category, status } = req.body || {}; + if (!project_id || !doc_no) + return res.status(400).json({ error: "project_id and doc_no required" }); + const [rs] = await sql.query( + `INSERT INTO documents (project_id, doc_no, title, category, status, created_by) + VALUES (?,?,?,?,?,?)`, + [ + project_id, + doc_no, + title || null, + category || null, + status || null, + req.principal.user_id, + ] + ); + res.status(201).json({ document_id: rs.insertId }); + } +); + +// UPDATE +r.patch("/:id", requirePerm("documents.manage"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query( + "SELECT * FROM documents WHERE document_id=?", + [id] + ); + if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); -r.patch('/documents/:id', requireAuth, enrichPermissions(), requireProjectMembershipByRecord({ modelLoader: (s)=> (await import('../db/models/Document.js')).default(s) }), enrichPermissions(), requirePerm('document:update'), async (req, res) => { - const row = await Doc.findByPk(Number(req.params.id)); - if (!row) return res.status(404).json({ error: 'Not found' }); const { title, category, status } = req.body || {}; - if (title !== undefined) row.title = title; - if (category !== undefined) row.category = category; - if (status !== undefined) row.status = status; - row.updated_by = req.user?.user_id; - await row.save(); + await sql.query( + "UPDATE documents SET title=?, category=?, status=?, updated_by=? WHERE document_id=?", + [ + title ?? row.title, + category ?? row.category, + status ?? row.status, + req.principal.user_id, + id, + ] + ); res.json({ ok: true }); }); -r.delete('/documents/:id', requireAuth, enrichPermissions(), requireProjectMembershipByRecord({ modelLoader: (s)=> (await import('../db/models/Document.js')).default(s) }), enrichPermissions(), requirePerm('document:delete'), async (req, res) => { - const row = await Doc.findByPk(Number(req.params.id)); - if (!row) return res.status(404).json({ error: 'Not found' }); - await row.destroy(); +// DELETE +r.delete("/:id", requirePerm("documents.manage"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query( + "SELECT * FROM documents WHERE document_id=?", + [id] + ); + if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + await sql.query("DELETE FROM documents WHERE document_id=?", [id]); res.json({ ok: true }); }); diff --git a/backend/src/routes/drawings.js b/backend/src/routes/drawings.js index aca0693b..46d1a8fb 100644 --- a/backend/src/routes/drawings.js +++ b/backend/src/routes/drawings.js @@ -1,124 +1,120 @@ -// FILE: src/routes/drawings.js -// 03.2 9) เพิ่ม routes/drawings.js (ใหม่) -// - ใช้ร่วมกับ requirePerm() และ buildScopeWhere() -// - สำหรับจัดการ drawings (ดู/เพิ่ม/แก้ไข/ลบ) ตามสิทธิ์ของผู้ใช้ -// Drawings routes -// - CRUD operations for drawings -// - Requires appropriate permissions via requirePerm middleware -// - Uses org scope for all permissions -// - drawing:read, drawing:create, drawing:update, drawing:delete -// - Drawing fields: id (PK), org_id, project_id, dwg_no, dwg_code, title, created_by -// - Basic validation: org_id, dwg_no required for create - +// FILE: backend/src/routes/drawings.js import { Router } from "express"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import { buildScopeWhere, ownerResolvers } from "../utils/scope.js"; -import PERM from "../config/permissions.js"; const r = Router(); -const OWN = ownerResolvers(sql, "drawings", "id"); // LIST r.get( "/", - requirePerm("drawing.read", { scope: "global" }), + requirePerm("drawings.view", { projectParam: "project_id" }), async (req, res) => { const { project_id, org_id, code, q, limit = 50, offset = 0 } = req.query; + const p = req.principal; + const params = []; + const cond = []; - const base = buildScopeWhere(req.principal, { - tableAlias: "d", - orgColumn: "d.org_id", - projectColumn: "d.project_id", - permCode: "drawing.read", - preferProject: true, - }); - - const extra = []; - const params = { - ...base.params, - limit: Number(limit), - offset: Number(offset), - }; - if (project_id) { - extra.push("d.project_id = :project_id"); - params.project_id = Number(project_id); + if (!p.is_superadmin) { + if (project_id) { + if (!p.inProject(Number(project_id))) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + cond.push("d.project_id=?"); + params.push(Number(project_id)); + } else if (p.project_ids?.length) { + cond.push( + `d.project_id IN (${p.project_ids.map(() => "?").join(",")})` + ); + params.push(...p.project_ids); + } + } else if (project_id) { + cond.push("d.project_id=?"); + params.push(Number(project_id)); } + if (org_id) { - extra.push("d.org_id = :org_id"); - params.org_id = Number(org_id); + cond.push("d.org_id=?"); + params.push(Number(org_id)); } if (code) { - extra.push("d.dwg_code = :code"); - params.code = code; + cond.push("d.dwg_code=?"); + params.push(code); } if (q) { - extra.push("(d.dwg_no LIKE :q OR d.title LIKE :q)"); - params.q = `%${q}%`; + cond.push("(d.dwg_no LIKE ? OR d.title LIKE ?)"); + params.push(`%${q}%`, `%${q}%`); } - const where = [base.where, ...extra].filter(Boolean).join(" AND "); - + const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; const [rows] = await sql.query( - `SELECT d.* FROM drawings d WHERE ${where} - ORDER BY d.id DESC LIMIT :limit OFFSET :offset`, - params + `SELECT d.* FROM drawings d ${where} ORDER BY d.id DESC LIMIT ? OFFSET ?`, + [...params, Number(limit), Number(offset)] ); res.json(rows); } ); // GET -r.get( - "/:id", - requirePerm("drawing.read", { scope: "org", getOrgId: OWN.getOrgIdById }), - async (req, res) => { - const id = Number(req.params.id); - const [[row]] = await sql.query("SELECT * FROM drawings WHERE id=?", [id]); - if (!row) return res.status(404).json({ error: "Not found" }); - res.json(row); - } -); +r.get("/:id", requirePerm("drawings.view"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM drawings WHERE id=?", [id]); + if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + res.json(row); +}); // CREATE r.post( "/", - requirePerm("drawing.create", { - scope: "org", - getOrgId: async (req) => req.body?.org_id ?? null, - }), + requirePerm("drawings.upload", { projectParam: "project_id" }), async (req, res) => { - const { org_id, project_id, dwg_no, dwg_code, title } = req.body; + const { org_id, project_id, dwg_no, dwg_code, title } = req.body || {}; + if (!project_id || !dwg_no) + return res.status(400).json({ error: "project_id and dwg_no required" }); const [rs] = await sql.query( `INSERT INTO drawings (org_id, project_id, dwg_no, dwg_code, title, created_by) - VALUES (?,?,?,?,?,?)`, - [org_id, project_id, dwg_no, dwg_code, title, req.principal.userId] + VALUES (?,?,?,?,?,?)`, + [ + org_id || null, + project_id, + dwg_no, + dwg_code || null, + title || null, + req.principal.user_id, + ] ); res.json({ id: rs.insertId }); } ); -// UPDATE -r.put( - "/:id", - requirePerm("drawing.update", { scope: "org", getOrgId: OWN.getOrgIdById }), - async (req, res) => { - const id = Number(req.params.id); - const { title } = req.body; - await sql.query("UPDATE drawings SET title=? WHERE id=?", [title, id]); - res.json({ ok: 1 }); - } -); +// UPDATE (ใช้สิทธิ์ drawings.upload) +r.put("/:id", requirePerm("drawings.upload"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM drawings WHERE id=?", [id]); + if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + const { title } = req.body || {}; + await sql.query("UPDATE drawings SET title=? WHERE id=?", [ + title ?? row.title, + id, + ]); + res.json({ ok: 1 }); +}); // DELETE -r.delete( - "/:id", - requirePerm("drawing.delete", { scope: "org", getOrgId: OWN.getOrgIdById }), - async (req, res) => { - const id = Number(req.params.id); - await sql.query("DELETE FROM drawings WHERE id=?", [id]); - res.json({ ok: 1 }); - } -); +r.delete("/:id", requirePerm("drawings.delete"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM drawings WHERE id=?", [id]); + if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + await sql.query("DELETE FROM drawings WHERE id=?", [id]); + res.json({ ok: 1 }); +}); export default r; diff --git a/backend/src/routes/files_extras.js b/backend/src/routes/files_extras.js index 3077e3b7..01b841ba 100644 --- a/backend/src/routes/files_extras.js +++ b/backend/src/routes/files_extras.js @@ -1,60 +1,47 @@ -// FILE: src/routes/files_extras.js -// Extra file-related routes -// - HEAD for file meta -// - DELETE to delete a file (physical + record) -// - POST to rename a file (meta only) -// - POST to refresh signed download URL -// - Requires authentication and appropriate permissions via requireAuth, requirePerm, and enrichPermissions middleware -// - Uses project scope for file access permissions -// - file:read, file:create, file:update, file:delete -// - File fields: file_id (PK), module, ref_id, orig_name, disk_path, mime, size, created_by - +// FILE: backend/src/routes/files_extras.js +// NOTE: generic file actions – ผูกสิทธิ์ตามโมดูลปลายทาง และบังคับ ABAC จาก project_id ของเรคคอร์ด import { Router } from "express"; -import fs from "fs"; -import path from "path"; -import jwt from "jsonwebtoken"; -import { requireAuth } from "../middleware/auth.js"; -import { enrichPermissions } from "../middleware/permissions.js"; -import { requireRole } from "../middleware/rbac.js"; -import { requirePerm } from "../middleware/permGuard.js"; -import { sequelize } from "../db/sequelize.js"; -import FileModel from "../db/models/FileObject.js"; -import { config } from "../config.js"; +import fs from "node:fs"; +import sql from "../db/index.js"; const r = Router(); -const Files = FileModel(sequelize); async function projectForFile(rec) { - const mod = rec.module; - const refId = rec.ref_id; - switch (mod) { + // โปรเจ็คของไฟล์อิงโมดูล/ตารางอ้างอิง + switch (rec.module) { case "rfa": { - const M = (await import("../db/models/RFA.js")).default(sequelize); - const row = await M.findByPk(refId); + const [[row]] = await sql.query( + "SELECT project_id FROM rfas WHERE id=?", + [rec.ref_id] + ); return row?.project_id || null; } case "correspondence": { - const M = (await import("../db/models/Correspondence.js")).default( - sequelize + const [[row]] = await sql.query( + "SELECT project_id FROM correspondences WHERE id=?", + [rec.ref_id] ); - const row = await M.findByPk(refId); return row?.project_id || null; } case "drawing": { - const M = (await import("../db/models/Drawing.js")).default(sequelize); - const row = await M.findByPk(refId); + const [[row]] = await sql.query( + "SELECT project_id FROM drawings WHERE id=?", + [rec.ref_id] + ); return row?.project_id || null; } case "document": { - const M = (await import("../db/models/Document.js")).default(sequelize); - const row = await M.findByPk(refId); + const [[row]] = await sql.query( + "SELECT project_id FROM documents WHERE document_id=?", + [rec.ref_id] + ); return row?.project_id || null; } case "transmittal": { - const M = (await import("../db/models/Transmittal.js")).default( - sequelize + const [[row]] = await sql.query( + "SELECT project_id FROM transmittals WHERE id=?", + [rec.ref_id] ); - const row = await M.findByPk(refId); return row?.project_id || null; } default: @@ -62,90 +49,106 @@ async function projectForFile(rec) { } } -// HEAD meta only -r.head("/files/:file_id", requireAuth, async (req, res) => { - const rec = await Files.findByPk(Number(req.params.file_id)); +function permForFile(rec, action /* 'read'|'update'|'delete' */) { + // map เป็น permission ของโมดูลจริง + const m = rec.module; + if (m === "document") + return action === "read" ? "documents.view" : "documents.manage"; + if (m === "drawing") + return action === "read" + ? "drawings.view" + : action === "delete" + ? "drawings.delete" + : "drawings.upload"; + if (m === "correspondence") + return action === "read" ? "corr.view" : "corr.manage"; + if (m === "rfa") return action === "read" ? "rfas.view" : "rfas.respond"; + if (m === "transmittal") return "transmittals.manage"; + return "documents.manage"; // fallback +} + +// HEAD meta +r.head("/files/:file_id", async (req, res) => { + const id = Number(req.params.file_id); + const [[rec]] = await sql.query("SELECT * FROM files WHERE file_id=?", [id]); if (!rec) return res.status(404).end(); res.setHeader("Content-Type", rec.mime || "application/octet-stream"); res.setHeader("Content-Length", String(rec.size || 0)); res.status(200).end(); }); -// delete (soft delete is recommended; here we do physical delete + record delete) -r.delete( - "/files/:file_id", - requireAuth, - enrichPermissions(), - requirePerm("file:delete"), - async (req, res) => { - const rec = await Files.findByPk(Number(req.params.file_id)); - if (!rec) return res.status(404).json({ error: "Not found" }); - const pid = await projectForFile(rec); - const roles = req.user?.roles || []; - const isAdmin = roles.includes("Admin"); - if (!isAdmin) { - const { getUserProjectIds } = await import("../middleware/abac.js"); - const memberProjects = await getUserProjectIds(req.user?.user_id); - if (!memberProjects.includes(pid)) - return res - .status(403) - .json({ error: "Forbidden: not a project member" }); - } - try { - fs.unlinkSync(rec.disk_path); - } catch {} - await rec.destroy(); - res.json({ ok: true }); - } -); - -// rename (meta only - keep disk file name) -r.post( - "/files/:file_id/rename", - requireAuth, - enrichPermissions(), - requirePerm("file:update"), - async (req, res) => { - const rec = await Files.findByPk(Number(req.params.file_id)); - if (!rec) return res.status(404).json({ error: "Not found" }); - const pid = await projectForFile(rec); - const roles = req.user?.roles || []; - const isAdmin = roles.includes("Admin"); - if (!isAdmin) { - const { getUserProjectIds } = await import("../middleware/abac.js"); - const memberProjects = await getUserProjectIds(req.user?.user_id); - if (!memberProjects.includes(pid)) - return res - .status(403) - .json({ error: "Forbidden: not a project member" }); - } - const { orig_name } = req.body || {}; - if (!orig_name) - return res.status(400).json({ error: "orig_name required" }); - rec.orig_name = orig_name; - await rec.save(); - res.json({ ok: true }); - } -); - -// refresh signed download url -r.post("/files/:file_id/refresh-url", requireAuth, async (req, res) => { - const rec = await Files.findByPk(Number(req.params.file_id)); +// DELETE +r.delete("/files/:file_id", async (req, res) => { + const id = Number(req.params.file_id); + const [[rec]] = await sql.query("SELECT * FROM files WHERE file_id=?", [id]); if (!rec) return res.status(404).json({ error: "Not found" }); + + const p = req.principal; + if (!p) return res.status(401).json({ error: "Unauthenticated" }); + const pid = await projectForFile(rec); - const roles = req.user?.roles || []; - const isAdmin = roles.includes("Admin"); - if (!isAdmin) { - const { getUserProjectIds } = await import("../middleware/abac.js"); - const memberProjects = await getUserProjectIds(req.user?.user_id); - if (!memberProjects.includes(pid)) - return res.status(403).json({ error: "Forbidden: not a project member" }); + if (!p.is_superadmin) { + if (!pid || !p.inProject(pid)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + const need = permForFile(rec, "delete"); + if (!p.can?.(need)) + return res.status(403).json({ error: "FORBIDDEN", need }); } + + try { + if (rec.disk_path) fs.unlinkSync(rec.disk_path); + } catch {} + await sql.query("DELETE FROM files WHERE file_id=?", [id]); + res.json({ ok: true }); +}); + +// RENAME (meta only) +r.post("/files/:file_id/rename", async (req, res) => { + const id = Number(req.params.file_id); + const [[rec]] = await sql.query("SELECT * FROM files WHERE file_id=?", [id]); + if (!rec) return res.status(404).json({ error: "Not found" }); + + const p = req.principal; + if (!p) return res.status(401).json({ error: "Unauthenticated" }); + + const pid = await projectForFile(rec); + if (!p.is_superadmin) { + if (!pid || !p.inProject(pid)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + const need = permForFile(rec, "update"); + if (!p.can?.(need)) + return res.status(403).json({ error: "FORBIDDEN", need }); + } + + const { orig_name } = req.body || {}; + if (!orig_name) return res.status(400).json({ error: "orig_name required" }); + await sql.query("UPDATE files SET orig_name=? WHERE file_id=?", [ + orig_name, + id, + ]); + res.json({ ok: true }); +}); + +// refresh signed download URL – ปกติใช้ signed URL service ภายนอก; ที่นี่คืน URL ภายในเป็นตัวอย่าง +r.post("/files/:file_id/refresh-url", async (req, res) => { + const id = Number(req.params.file_id); + const [[rec]] = await sql.query("SELECT * FROM files WHERE file_id=?", [id]); + if (!rec) return res.status(404).json({ error: "Not found" }); + + const p = req.principal; + if (!p) return res.status(401).json({ error: "Unauthenticated" }); + + const pid = await projectForFile(rec); + if (!p.is_superadmin) { + if (!pid || !p.inProject(pid)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + const need = permForFile(rec, "read"); + if (!p.can?.(need)) + return res.status(403).json({ error: "FORBIDDEN", need }); + } + const expSec = Number(process.env.FILE_URL_EXPIRES || 600); - const token = jwt.sign({ file_id: rec.file_id }, config.JWT.SECRET, { - expiresIn: expSec, - }); - const download_url = `/api/v1/files/${rec.file_id}?token=${token}`; + const download_url = `/api/files/${rec.file_id}?token=internal-placeholder&exp=${expSec}`; res.json({ download_url, expires_in: expSec }); }); diff --git a/backend/src/routes/health.js b/backend/src/routes/health.js index 60e71a23..9645ac03 100644 --- a/backend/src/routes/health.js +++ b/backend/src/routes/health.js @@ -1,18 +1,19 @@ -// FILE: src/routes/health.js -// Health check route -// - GET /health to check server and database status -// - Requires appropriate permissions via requirePerm middleware - +// FILE: backend/src/routes/health.js import { Router } from "express"; -import { sequelize } from "../db/sequelize.js"; +import sql from "../db/index.js"; const r = Router(); + +// /api/health — ไม่ต้องใช้สิทธิ์ r.get("/health", async (_req, res) => { try { - await sequelize.query("SELECT 1 AS ok"); - res.status(200).json({ ok: true, db: "up" }); + const [[{ now }]] = await sql.query("SELECT NOW() AS now"); + res.status(200).json({ ok: true, db: "up", now }); } catch (e) { - res.status(500).json({ ok: false, db: "down", error: String(e) }); + res + .status(500) + .json({ ok: false, db: "down", error: String(e?.message || e) }); } }); + export default r; diff --git a/backend/src/routes/list.txt b/backend/src/routes/list.txt deleted file mode 100644 index 4f2e865b..00000000 --- a/backend/src/routes/list.txt +++ /dev/null @@ -1,41 +0,0 @@ - Volume in drive S is Container - Volume Serial Number is 1F5F-1DEB - - Directory of S:\backend\src\routes - -11 Sep 25 16:34 . -11 Sep 25 16:33 .. -10 Sep 25 16:42 1,713 auth.js -11 Sep 25 16:20 1,097 users.js -11 Sep 25 16:20 2,624 correspondences.js -08 Sep 25 08:58 1,306 rfa.js -11 Sep 25 16:20 2,671 transmittals.js -08 Sep 25 08:58 2,448 technicaldocs.js -08 Sep 25 08:58 2,410 contractdwg.js -08 Sep 25 08:46 2,234 admin.js -10 Sep 25 16:42 359 health.js -10 Sep 25 17:35 501 auth_extras.js -10 Sep 25 18:18 2,980 documents.js -11 Sep 25 16:20 2,848 drawings.js -10 Sep 25 18:18 4,514 files_extras.js -10 Sep 25 17:35 1,222 lookups.js -10 Sep 25 18:18 4,285 maps.js -10 Sep 25 17:35 630 module_files.js -11 Sep 25 16:23 5,182 mvp.js -10 Sep 25 17:35 729 ops.js -11 Sep 25 16:20 1,197 organizations.js -11 Sep 25 16:20 2,266 projects.js -10 Sep 25 17:42 3,604 rbac_admin.js -11 Sep 25 16:20 2,826 rfas.js -10 Sep 25 17:53 2,376 subcategories.js -11 Sep 25 16:20 2,090 uploads.js -10 Sep 25 17:35 2,320 users_extras.js -10 Sep 25 18:18 4,766 views.js -11 Sep 25 16:20 1,199 volumes.js -11 Sep 25 16:20 479 permissions.js -11 Sep 25 16:20 2,751 contracts.js -11 Sep 25 16:20 2,630 contract_dwg.js -11 Sep 25 16:20 1,782 categories.js -11 Sep 25 16:34 148 list.txt - 32 File(s) 70,187 bytes - 2 Dir(s) 3,725,382,303,744 bytes free diff --git a/backend/src/routes/lookup.js b/backend/src/routes/lookup.js index e323d84b..6c38f98d 100644 --- a/backend/src/routes/lookup.js +++ b/backend/src/routes/lookup.js @@ -1,158 +1,125 @@ -// FILE: src/routes/lookup.js -// Lookup route -// - GET /api/lookup to fetch various lookup data (organizations, projects, categories, subcategories, volumes, permissions) -// - Requires appropriate permissions for each data type via requirePerm middleware -// - Supports query parameter 'pick' to specify which data to include (comma-separated, e.g. ?pick=org,project) -// - If 'pick' is not provided, returns all data types -// - Organizations and Projects are scoped based on user's permissions -// - Categories, Subcategories, Volumes, and Permissions are global master data - +// FILE: backend/src/routes/lookup.js import { Router } from "express"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import { buildScopeWhere } from "../utils/scope.js"; -import PERM from "../config/permissions.js"; const r = Router(); -/** - * ช่วยอ่าน query pick (คั่นด้วย comma) - */ -function parsePick(qs) { - if (!qs) return null; - return String(qs) - .split(",") - .map((s) => s.trim().toLowerCase()) - .filter(Boolean); -} - // GET /api/lookup?pick=org,project,category,subcategory,volume,permission -r.get( - "/", - // ต้องเป็นผู้ใช้ที่ยืนยันตัวตนแล้ว (middleware authJwt+loadPrincipal อยู่ใน /api) - // ไม่ require permission เดียว เพราะเราจะเช็คทีละชุดด้านล่าง - async (req, res) => { - const pick = new Set( - parsePick(req.query.pick) || [ - "org", - "project", - "category", - "subcategory", - "volume", - "permission", - ] - ); +r.get("/", async (req, res) => { + const picks = new Set( + String( + req.query.pick || "org,project,category,subcategory,volume,permission" + ) + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean) + ); - const result = {}; + const out = {}; - // 1) Organizations (scoped list) — require organization.read - if (pick.has("org")) { - // มีสิทธิ์ถึงจะดึง - const canOrg = - req.principal.isSuperAdmin || - req.principal.perms.has(PERM.organization.read); - if (canOrg) { - const { where, params } = buildScopeWhere(req.principal, { - tableAlias: "o", - orgColumn: "o.org_id", - projectColumn: "NULL", - permCode: PERM.organization.read, - }); - const [rows] = await sql.query( - `SELECT o.org_id, o.org_name FROM organizations o WHERE ${where} ORDER BY o.org_name`, - params - ); - result.organizations = rows; - } else { - result.organizations = []; - } + // Organizations — GLOBAL (อ่านได้ด้วย organizations.view) + if (picks.has("org")) { + try { + // มี perm ไหม? (GLOBAL) + const ok = + req.principal?.is_superadmin || + req.principal?.permissions?.has?.("organizations.view"); + out.organizations = ok + ? ( + await sql.query( + "SELECT org_id, org_name FROM organizations ORDER BY org_name" + ) + )[0] + : []; + } catch { + out.organizations = []; } - - // 2) Projects (scoped list) — require project.read - if (pick.has("project")) { - const canPrj = - req.principal.isSuperAdmin || - req.principal.perms.has(PERM.project.read); - if (canPrj) { - const { where, params } = buildScopeWhere(req.principal, { - tableAlias: "p", - orgColumn: "p.org_id", - projectColumn: "p.project_id", - permCode: PERM.project.read, - preferProject: true, - }); - const [rows] = await sql.query( - `SELECT p.project_id, p.org_id, p.project_code, p.project_name - FROM projects p WHERE ${where} ORDER BY p.project_name`, - params - ); - result.projects = rows; - } else { - result.projects = []; - } - } - - // 3) Categories (global master) — require category.read - if (pick.has("category")) { - const can = - req.principal.isSuperAdmin || - req.principal.perms.has(PERM.category.read); - if (can) { - const [rows] = await sql.query( - "SELECT cat_id, cat_code, cat_name FROM categories ORDER BY cat_name" - ); - result.categories = rows; - } else { - result.categories = []; - } - } - - // 4) Subcategories (global master) — require category.read - if (pick.has("subcategory")) { - const can = - req.principal.isSuperAdmin || - req.principal.perms.has(PERM.category.read); - if (can) { - const [rows] = await sql.query( - "SELECT sub_cat_id, cat_id, sub_cat_name FROM subcategories ORDER BY sub_cat_name" - ); - result.subcategories = rows; - } else { - result.subcategories = []; - } - } - - // 5) Volumes (global master) — require volume.read - if (pick.has("volume")) { - const can = - req.principal.isSuperAdmin || req.principal.perms.has(PERM.volume.read); - if (can) { - const [rows] = await sql.query( - "SELECT volume_id, volume_code, volume_name FROM volumes ORDER BY volume_code" - ); - result.volumes = rows; - } else { - result.volumes = []; - } - } - - // 6) Permissions (global master) — require permission.read - if (pick.has("permission")) { - const can = - req.principal.isSuperAdmin || - req.principal.perms.has(PERM.permission.read); - if (can) { - const [rows] = await sql.query( - "SELECT permission_id, permission_code, description FROM permissions ORDER BY permission_code" - ); - result.permissions = rows; - } else { - result.permissions = []; - } - } - - res.json(result); } -); + + // Projects — ORG scope (projects.view) + if (picks.has("project")) { + try { + const ok = + req.principal?.is_superadmin || + req.principal?.permissions?.has?.("projects.view"); + if (!ok) out.projects = []; + else { + // จำกัดตาม org scope + const p = req.principal; + let rows = []; + if (p.is_superadmin) { + [rows] = await sql.query( + "SELECT project_id, org_id, project_code, project_name FROM projects ORDER BY project_name" + ); + } else if (p.org_ids?.length) { + const inSql = p.org_ids.map(() => "?").join(","); + [rows] = await sql.query( + `SELECT project_id, org_id, project_code, project_name + FROM projects WHERE org_id IN (${inSql}) + ORDER BY project_name`, + p.org_ids + ); + } else { + rows = []; + } + out.projects = rows; + } + } catch { + out.projects = []; + } + } + + // Categories/Subcategories/Volumes — GLOBAL master (settings.manage ไม่จำเป็นสำหรับการอ่าน lookup) + if (picks.has("category")) { + try { + out.categories = ( + await sql.query( + "SELECT cat_id, cat_code, cat_name FROM categories ORDER BY cat_name" + ) + )[0]; + } catch { + out.categories = []; + } + } + if (picks.has("subcategory")) { + try { + out.subcategories = ( + await sql.query( + "SELECT sub_cat_id, cat_id, sub_cat_name FROM subcategories ORDER BY sub_cat_name" + ) + )[0]; + } catch { + out.subcategories = []; + } + } + if (picks.has("volume")) { + try { + out.volumes = ( + await sql.query( + "SELECT volume_id, volume_code, volume_name FROM volumes ORDER BY volume_code" + ) + )[0]; + } catch { + out.volumes = []; + } + } + + // Permissions — GLOBAL (settings.manage เท่านั้นที่ควรเห็นทั้งหมด) + if (picks.has("permission")) { + const ok = + req.principal?.is_superadmin || + req.principal?.permissions?.has?.("settings.manage"); + out.permissions = ok + ? ( + await sql.query( + "SELECT permission_id, perm_code AS permission_code, description FROM permissions ORDER BY perm_code" + ) + )[0] + : []; + } + + res.json(out); +}); export default r; diff --git a/backend/src/routes/maps.js b/backend/src/routes/maps.js index ed45548a..d4e9a6c9 100644 --- a/backend/src/routes/maps.js +++ b/backend/src/routes/maps.js @@ -1,149 +1,162 @@ -// FILE: src/routes/maps.js -// Maps routes -// - Manage relationships between RFAs and Drawings, Correspondences and Documents -// - Requires appropriate permissions via requirePerm middleware -// - Uses project scope for RFA-Drawing maps and Correspondence-Document maps -// - rfa:update for RFA-Drawing maps -// - correspondence:update for Correspondence-Document maps - +// FILE: backend/src/routes/maps.js +// Map ความสัมพันธ์ระหว่าง RFA<->Drawing และ Correspondence<->Document import { Router } from "express"; -import { requireAuth } from "../middleware/auth.js"; -import { enrichPermissions } from "../middleware/permissions.js"; -import { requireRole } from "../middleware/rbac.js"; -import { requirePerm } from "../middleware/permGuard.js"; -import { sequelize } from "../db/sequelize.js"; -import RfaModel from "../db/models/RFA.js"; -import DrawingModel from "../db/models/Drawing.js"; -import RfaDrawMapModel from "../db/models/RfaDrawingMap.js"; -import CorrModel from "../db/models/Correspondence.js"; -import DocModel from "../db/models/Document.js"; -import CorrDocMapModel from "../db/models/CorrDocumentMap.js"; +import sql from "../db/index.js"; +import { requirePerm } from "../middleware/requirePerm.js"; const r = Router(); -const RFA = RfaModel(sequelize); -const Drawing = DrawingModel(sequelize); -const RfaDraw = RfaDrawMapModel(sequelize); -const Corr = CorrModel(sequelize); -const Doc = DocModel(sequelize); -const CorrDoc = CorrDocMapModel(sequelize); -async function ensureRfaMembership(req, res) { - const rfaId = Number(req.params.rfa_id); - const row = await RFA.findByPk(rfaId); - if (!row) { - res.status(404).json({ error: "RFA not found" }); - return false; - } - const roles = req.user?.roles || []; - const isAdmin = roles.includes("Admin"); - if (isAdmin) return true; - const { getUserProjectIds } = await import("../middleware/abac.js"); - const memberProjects = await getUserProjectIds(req.user?.user_id); - if (!memberProjects.includes(Number(row.project_id))) { - res.status(403).json({ error: "Forbidden: not a project member" }); - return false; - } - return true; -} - -async function ensureCorrMembership(req, res) { - const corrId = Number(req.params.corr_id); - const row = await Corr.findByPk(corrId); - if (!row) { - res.status(404).json({ error: "Correspondence not found" }); - return false; - } - const roles = req.user?.roles || []; - const isAdmin = roles.includes("Admin"); - if (isAdmin) return true; - const { getUserProjectIds } = await import("../middleware/abac.js"); - const memberProjects = await getUserProjectIds(req.user?.user_id); - if (!memberProjects.includes(Number(row.project_id))) { - res.status(403).json({ error: "Forbidden: not a project member" }); - return false; - } - return true; -} - -// RFA <-> Drawing -r.get("/maps/rfa/:rfa_id/drawings", requireAuth, async (req, res) => { - const rows = await RfaDraw.findAll({ - where: { rfa_id: Number(req.params.rfa_id) }, - }); - res.json(rows); -}); -r.post( - "/maps/rfa/:rfa_id/drawings/:drawing_id", - requireAuth, - enrichPermissions(), - requirePerm("rfa:update"), - async (req, res) => { - if (!(await ensureRfaMembership(req, res))) return; - const { rfa_id, drawing_id } = { - rfa_id: Number(req.params.rfa_id), - drawing_id: Number(req.params.drawing_id), - }; - await RfaDraw.create({ rfa_id, drawing_id }); - res.status(201).json({ ok: true }); - } -); -r.delete( - "/maps/rfa/:rfa_id/drawings/:drawing_id", - requireAuth, - enrichPermissions(), - requirePerm("rfa:update"), - async (req, res) => { - if (!(await ensureRfaMembership(req, res))) return; - const { rfa_id, drawing_id } = { - rfa_id: Number(req.params.rfa_id), - drawing_id: Number(req.params.drawing_id), - }; - const count = await RfaDraw.destroy({ where: { rfa_id, drawing_id } }); - res.json({ ok: count > 0 }); - } -); - -// Correspondence <-> Document +// ========= RFA <-> Drawing ========= +// LIST r.get( - "/maps/correspondence/:corr_id/documents", - requireAuth, + "/maps/rfa/:rfa_id/drawings", + requirePerm("rfas.view", { projectParam: "project_id" }), // ABAC enforced เมื่อส่ง query project_id; ถ้าไม่ส่งเราจะตรวจจากเรคคอร์ด async (req, res) => { - const rows = await CorrDoc.findAll({ - where: { correspondence_id: Number(req.params.corr_id) }, - }); + const rfa_id = Number(req.params.rfa_id); + const [[rfa]] = await sql.query("SELECT project_id FROM rfas WHERE id=?", [ + rfa_id, + ]); + if (!rfa) return res.status(404).json({ error: "RFA not found" }); + if ( + !req.principal.is_superadmin && + !req.principal.inProject(rfa.project_id) + ) { + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + } + const [rows] = await sql.query( + `SELECT m.* FROM rfa_drawing_map m WHERE m.rfa_id=? ORDER BY m.id DESC`, + [rfa_id] + ); res.json(rows); } ); + +// ADD r.post( - "/maps/correspondence/:corr_id/documents/:doc_id", - requireAuth, - enrichPermissions(), - requirePerm("correspondence:update"), + "/maps/rfa/:rfa_id/drawings/:drawing_id", + requirePerm("rfas.respond", { projectParam: "project_id" }), async (req, res) => { - if (!(await ensureCorrMembership(req, res))) return; - const { corr_id, doc_id } = { - corr_id: Number(req.params.corr_id), - doc_id: Number(req.params.doc_id), - }; - await CorrDoc.create({ correspondence_id: corr_id, document_id: doc_id }); + const rfa_id = Number(req.params.rfa_id); + const drawing_id = Number(req.params.drawing_id); + + const [[rfa]] = await sql.query("SELECT project_id FROM rfas WHERE id=?", [ + rfa_id, + ]); + if (!rfa) return res.status(404).json({ error: "RFA not found" }); + if ( + !req.principal.is_superadmin && + !req.principal.inProject(rfa.project_id) + ) { + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + } + await sql.query( + "INSERT IGNORE INTO rfa_drawing_map (rfa_id, drawing_id, created_by) VALUES (?,?,?)", + [rfa_id, drawing_id, req.principal.user_id] + ); res.status(201).json({ ok: true }); } ); + +// REMOVE +r.delete( + "/maps/rfa/:rfa_id/drawings/:drawing_id", + requirePerm("rfas.respond"), + async (req, res) => { + const rfa_id = Number(req.params.rfa_id); + const drawing_id = Number(req.params.drawing_id); + const [[rfa]] = await sql.query("SELECT project_id FROM rfas WHERE id=?", [ + rfa_id, + ]); + if (!rfa) return res.status(404).json({ error: "RFA not found" }); + if ( + !req.principal.is_superadmin && + !req.principal.inProject(rfa.project_id) + ) { + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + } + const [rs] = await sql.query( + "DELETE FROM rfa_drawing_map WHERE rfa_id=? AND drawing_id=?", + [rfa_id, drawing_id] + ); + res.json({ ok: rs.affectedRows > 0 }); + } +); + +// ========= Correspondence <-> Document ========= +r.get( + "/maps/correspondence/:corr_id/documents", + requirePerm("corr.view", { projectParam: "project_id" }), + async (req, res) => { + const corr_id = Number(req.params.corr_id); + const [[corr]] = await sql.query( + "SELECT project_id FROM correspondences WHERE id=?", + [corr_id] + ); + if (!corr) + return res.status(404).json({ error: "Correspondence not found" }); + if ( + !req.principal.is_superadmin && + !req.principal.inProject(corr.project_id) + ) { + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + } + const [rows] = await sql.query( + `SELECT m.* FROM corr_document_map m WHERE m.correspondence_id=? ORDER BY m.id DESC`, + [corr_id] + ); + res.json(rows); + } +); + +r.post( + "/maps/correspondence/:corr_id/documents/:doc_id", + requirePerm("corr.manage", { projectParam: "project_id" }), + async (req, res) => { + const corr_id = Number(req.params.corr_id); + const doc_id = Number(req.params.doc_id); + const [[corr]] = await sql.query( + "SELECT project_id FROM correspondences WHERE id=?", + [corr_id] + ); + if (!corr) + return res.status(404).json({ error: "Correspondence not found" }); + if ( + !req.principal.is_superadmin && + !req.principal.inProject(corr.project_id) + ) { + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + } + await sql.query( + "INSERT IGNORE INTO corr_document_map (correspondence_id, document_id, created_by) VALUES (?,?,?)", + [corr_id, doc_id, req.principal.user_id] + ); + res.status(201).json({ ok: true }); + } +); + r.delete( "/maps/correspondence/:corr_id/documents/:doc_id", - requireAuth, - enrichPermissions(), - requirePerm("correspondence:update"), + requirePerm("corr.manage"), async (req, res) => { - if (!(await ensureCorrMembership(req, res))) return; - const { corr_id, doc_id } = { - corr_id: Number(req.params.corr_id), - doc_id: Number(req.params.doc_id), - }; - const count = await CorrDoc.destroy({ - where: { correspondence_id: corr_id, document_id: doc_id }, - }); - res.json({ ok: count > 0 }); + const corr_id = Number(req.params.corr_id); + const doc_id = Number(req.params.doc_id); + const [[corr]] = await sql.query( + "SELECT project_id FROM correspondences WHERE id=?", + [corr_id] + ); + if (!corr) + return res.status(404).json({ error: "Correspondence not found" }); + if ( + !req.principal.is_superadmin && + !req.principal.inProject(corr.project_id) + ) { + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + } + const [rs] = await sql.query( + "DELETE FROM corr_document_map WHERE correspondence_id=? AND document_id=?", + [corr_id, doc_id] + ); + res.json({ ok: rs.affectedRows > 0 }); } ); diff --git a/backend/src/routes/module_files.js b/backend/src/routes/module_files.js index 34cedecd..ab2ef92f 100644 --- a/backend/src/routes/module_files.js +++ b/backend/src/routes/module_files.js @@ -1,25 +1,69 @@ -// FILE: src/routes/module_files.js -// Module files routes -// - GET /:module(s)/:id/files to list files for various modules (rfa, correspondence, drawing, document, transmittal) -// - Requires authentication via requireAuth middleware -// - Uses project scope for file access permissions -// - file:read permission required - +// FILE: backend/src/routes/module_files.js import { Router } from "express"; -import { requireAuth } from "../middleware/auth.js"; -import { sequelize } from "../db/sequelize.js"; -import FileModel from "../db/models/FileObject.js"; +import sql from "../db/index.js"; const r = Router(); -const Files = FileModel(sequelize); -async function listBy(mod, ref_id) { - return Files.findAll({ - where: { module: mod, ref_id }, - order: [["created_at", "DESC"]], - }); +// อ่านไฟล์ของแต่ละโมดูล โดยเช็ค ABAC + permission จาก principal +function readPermFor(mod) { + switch (mod) { + case "rfa": + return "rfas.view"; + case "correspondence": + return "corr.view"; + case "drawing": + return "drawings.view"; + case "document": + return "documents.view"; + case "transmittal": + return "transmittals.manage"; // โมดูลนี้ seed เป็น manage + default: + return "documents.view"; + } +} +async function projectOf(mod, id) { + switch (mod) { + case "rfa": { + const [[row]] = await sql.query( + "SELECT project_id FROM rfas WHERE id=?", + [id] + ); + return row?.project_id || null; + } + case "correspondence": { + const [[row]] = await sql.query( + "SELECT project_id FROM correspondences WHERE id=?", + [id] + ); + return row?.project_id || null; + } + case "drawing": { + const [[row]] = await sql.query( + "SELECT project_id FROM drawings WHERE id=?", + [id] + ); + return row?.project_id || null; + } + case "document": { + const [[row]] = await sql.query( + "SELECT project_id FROM documents WHERE document_id=?", + [id] + ); + return row?.project_id || null; + } + case "transmittal": { + const [[row]] = await sql.query( + "SELECT project_id FROM transmittals WHERE id=?", + [id] + ); + return row?.project_id || null; + } + default: + return null; + } } +// /:module(s)/:id/files for (const mod of [ "rfa", "correspondence", @@ -27,9 +71,26 @@ for (const mod of [ "document", "transmittal", ]) { - r.get(`/${mod}s/:id/files`, requireAuth, async (req, res) => { - const items = await listBy(mod, Number(req.params.id)); - res.json(items); + r.get(`/${mod}s/:id/files`, async (req, res) => { + const ref_id = Number(req.params.id); + const p = req.principal; + if (!p) return res.status(401).json({ error: "Unauthenticated" }); + + const need = readPermFor(mod); + if (!(p.is_superadmin || p.permissions?.has?.(need))) { + return res.status(403).json({ error: "FORBIDDEN", need }); + } + + const pid = await projectOf(mod, ref_id); + if (!p.is_superadmin && (!pid || !p.inProject(pid))) { + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + } + + const [rows] = await sql.query( + `SELECT f.* FROM files f WHERE f.module=? AND f.ref_id=? ORDER BY f.file_id DESC`, + [mod, ref_id] + ); + res.json(rows); }); } diff --git a/backend/src/routes/mvp.js b/backend/src/routes/mvp.js index a79ae291..c20689bd 100644 --- a/backend/src/routes/mvp.js +++ b/backend/src/routes/mvp.js @@ -1,24 +1,14 @@ -// FILE: src/routes/maps.js -// Maps routes -// - Manage relationships between RFAs and Drawings, Correspondences and Documents -// - Requires appropriate permissions via requirePerm middleware -// - Uses project scope for RFA-Drawing maps and Correspondence-Document maps -// - rfa:update for RFA-Drawing maps -// - correspondence:update for Correspondence-Document maps - +// FILE: backend/src/routes/mvp.js import { Router } from "express"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import { buildScopeWhere, ownerResolvers } from "../utils/scope.js"; -import PERM from "../config/permissions.js"; const r = Router(); -const OWN = ownerResolvers(sql, "entity_maps", "id"); -// LIST +// LIST — projects.view (ORG scope) r.get( "/", - requirePerm(PERM.map.read, { scope: "global" }), + requirePerm("projects.view", { orgParam: "org_id" }), async (req, res) => { const { project_id, @@ -29,60 +19,55 @@ r.get( limit = 100, offset = 0, } = req.query; + const p = req.principal; + const params = []; + const cond = []; - const base = buildScopeWhere(req.principal, { - tableAlias: "m", - orgColumn: "m.org_id", - projectColumn: "m.project_id", - permCode: PERM.map.read, - preferProject: true, - }); - - const extra = []; - const params = { - ...base.params, - limit: Number(limit), - offset: Number(offset), - }; - if (project_id) { - extra.push("m.project_id = :project_id"); - params.project_id = Number(project_id); + if (!p.is_superadmin) { + if (org_id) { + if (!p.inOrg(Number(org_id))) + return res.status(403).json({ error: "FORBIDDEN_ORG" }); + cond.push("m.org_id=?"); + params.push(Number(org_id)); + } else if (p.org_ids?.length) { + cond.push(`m.org_id IN (${p.org_ids.map(() => "?").join(",")})`); + params.push(...p.org_ids); + } + } else if (org_id) { + cond.push("m.org_id=?"); + params.push(Number(org_id)); } - if (org_id) { - extra.push("m.org_id = :org_id"); - params.org_id = Number(org_id); + + if (project_id) { + cond.push("m.project_id=?"); + params.push(Number(project_id)); } if (module) { - extra.push("m.module = :module"); - params.module = module; + cond.push("m.module=?"); + params.push(module); } if (src_type) { - extra.push("m.src_type = :src_type"); - params.src_type = src_type; + cond.push("m.src_type=?"); + params.push(src_type); } if (dst_type) { - extra.push("m.dst_type = :dst_type"); - params.dst_type = dst_type; + cond.push("m.dst_type=?"); + params.push(dst_type); } - const where = [base.where, ...extra].filter(Boolean).join(" AND "); + const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; const [rows] = await sql.query( - `SELECT m.* FROM entity_maps m - WHERE ${where} - ORDER BY m.id DESC LIMIT :limit OFFSET :offset`, - params + `SELECT m.* FROM entity_maps m ${where} ORDER BY m.id DESC LIMIT ? OFFSET ?`, + [...params, Number(limit), Number(offset)] ); res.json(rows); } ); -// CREATE +// CREATE — projects.manage (ORG scope) r.post( "/", - requirePerm(PERM.map.create, { - scope: "org", - getOrgId: async (req) => req.body?.org_id ?? null, - }), + requirePerm("projects.manage", { orgParam: "org_id" }), async (req, res) => { const { org_id, @@ -93,30 +78,34 @@ r.post( dst_type, dst_id, remark, - } = req.body; + } = req.body || {}; + if (!org_id || !project_id || !module) + return res + .status(400) + .json({ error: "org_id, project_id, module required" }); const [rs] = await sql.query( `INSERT INTO entity_maps (org_id, project_id, module, src_type, src_id, dst_type, dst_id, remark, created_by) VALUES (?,?,?,?,?,?,?,?,?)`, [ - org_id, - project_id, + Number(org_id), + Number(project_id), module, - src_type, - Number(src_id), - dst_type, - Number(dst_id), - remark ?? null, - req.principal.userId, + src_type || null, + src_id ? Number(src_id) : null, + dst_type || null, + dst_id ? Number(dst_id) : null, + remark || null, + req.principal.user_id, ] ); res.json({ id: rs.insertId }); } ); -// DELETE (by id) +// DELETE — projects.manage (ORG scope) r.delete( "/:id", - requirePerm(PERM.map.delete, { scope: "org", getOrgId: OWN.getOrgIdById }), + requirePerm("projects.manage", { orgParam: "org_id" }), async (req, res) => { const id = Number(req.params.id); await sql.query("DELETE FROM entity_maps WHERE id=?", [id]); diff --git a/backend/src/routes/ops.js b/backend/src/routes/ops.js index dcc08a90..d2475061 100644 --- a/backend/src/routes/ops.js +++ b/backend/src/routes/ops.js @@ -1,22 +1,17 @@ -// FILE: src/routes/ops.js -// Ops routes -// - GET /ready for readiness check (DB connection) -// - GET /live for liveness check -// - GET /version to get app version from package.json - +// FILE: backend/src/routes/ops.js import { Router } from "express"; -import { sequelize } from "../db/sequelize.js"; -import fs from "fs"; -import path from "path"; +import sql from "../db/index.js"; +import fs from "node:fs"; +import path from "node:path"; const r = Router(); r.get("/ready", async (_req, res) => { try { - await sequelize.query("SELECT 1"); - return res.json({ ready: true }); + await sql.query("SELECT 1"); + res.json({ ready: true }); } catch { - return res.status(500).json({ ready: false }); + res.status(500).json({ ready: false }); } }); diff --git a/backend/src/routes/organizations.js b/backend/src/routes/organizations.js index 1f6fe2e6..af1da2de 100644 --- a/backend/src/routes/organizations.js +++ b/backend/src/routes/organizations.js @@ -1,59 +1,52 @@ -// FILE: src/routes/organizations.js -// 03.2 5) เพิ่ม routes/organizations.js (ใหม่) -// - ใช้ร่วมกับ requirePerm() และ buildScopeWhere() -// - สำหรับจัดการ organizations (ดู/เพิ่ม/แก้ไข/ลบ) ตามสิทธิ์ของผู้ใช้ -// Organizations routes -// - CRUD operations for organizations -// - Requires appropriate permissions via requirePerm middleware -// - Uses org scope for all permissions -// - organization:read, organization:create, organization:update, organization:delete -// - Organization fields: org_id (PK), org_name -// - Basic validation: org_name required for create - +// FILE: backend/src/routes/organizations.js import { Router } from "express"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import { buildScopeWhere } from "../utils/scope.js"; const r = Router(); -// LIST (org) – ดูเฉพาะ org ใน scope -r.get( - "/", - requirePerm("organization.read", { scope: "global" }), - async (req, res) => { - const { where, params } = buildScopeWhere(req.principal, { - tableAlias: "o", - orgColumn: "o.org_id", - projectColumn: "NULL", - permCode: "organization.read", - }); +// LIST +r.get("/", requirePerm("organizations.view"), async (_req, res) => { + const [rows] = await sql.query( + "SELECT * FROM organizations ORDER BY org_name" + ); + res.json(rows); +}); - const [rows] = await sql.query( - `SELECT o.* FROM organizations o WHERE ${where}`, - params - ); - res.json(rows); - } -); +// GET +r.get("/:id", requirePerm("organizations.view"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query( + "SELECT * FROM organizations WHERE org_id=?", + [id] + ); + if (!row) return res.status(404).json({ error: "Not found" }); + res.json(row); +}); -// GET by id -r.get( - "/:id", - requirePerm("organization.read", { - scope: "org", - getOrgId: async (req) => Number(req.params.id), - }), - async (req, res) => { - const id = Number(req.params.id); - const [[row]] = await sql.query( - "SELECT * FROM organizations WHERE org_id=?", - [id] - ); - if (!row) return res.status(404).json({ error: "Not found" }); - res.json(row); - } -); +// CREATE / UPDATE / DELETE — settings.manage +r.post("/", requirePerm("settings.manage"), async (req, res) => { + const { org_name } = req.body || {}; + if (!org_name) return res.status(400).json({ error: "org_name required" }); + const [rs] = await sql.query( + "INSERT INTO organizations (org_name) VALUES (?)", + [org_name] + ); + res.status(201).json({ org_id: rs.insertId }); +}); +r.put("/:id", requirePerm("settings.manage"), async (req, res) => { + const id = Number(req.params.id); + const { org_name } = req.body || {}; + await sql.query("UPDATE organizations SET org_name=? WHERE org_id=?", [ + org_name, + id, + ]); + res.json({ ok: 1 }); +}); +r.delete("/:id", requirePerm("settings.manage"), async (req, res) => { + const id = Number(req.params.id); + await sql.query("DELETE FROM organizations WHERE org_id=?", [id]); + res.json({ ok: 1 }); +}); -// CREATE/UPDATE/DELETE ตามสิทธิ์ของคุณ (optional) export default r; diff --git a/backend/src/routes/permissions.js b/backend/src/routes/permissions.js index c8685449..5436617a 100644 --- a/backend/src/routes/permissions.js +++ b/backend/src/routes/permissions.js @@ -1,27 +1,16 @@ -// FILE: src/routes/permissions.js -// 03.2 12) เพิ่ม routes/permissions.js (ใหม่) -// - ใช้ร่วมกับ requirePerm() -// - สำหรับดูรายชื่อสิทธิ์ทั้งหมด -// Permissions route -// - GET /api/permissions to list all permissions (permission_id, permission_code, description) -// - Requires global permission.read permission via requirePerm middleware - +// FILE: backend/src/routes/permissions.js import { Router } from "express"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import PERM from "../config/permissions.js"; const r = Router(); -r.get( - "/", - requirePerm("permission.read", { scope: "global" }), - async (req, res) => { - const [rows] = await sql.query( - "SELECT permission_id, permission_code, description FROM permissions ORDER BY permission_code" - ); - res.json(rows); - } -); +// GLOBAL: settings.manage จึงเห็นได้ทั้งหมด +r.get("/", requirePerm("settings.manage"), async (_req, res) => { + const [rows] = await sql.query( + "SELECT permission_id, perm_code AS permission_code, scope_level, description FROM permissions ORDER BY perm_code" + ); + res.json(rows); +}); export default r; diff --git a/backend/src/routes/projects.js b/backend/src/routes/projects.js index 2c508b45..33d28585 100644 --- a/backend/src/routes/projects.js +++ b/backend/src/routes/projects.js @@ -1,50 +1,49 @@ -// FILE: src/routes/projects.js -// 03.2 6) เพิ่ม routes/projects.js (ใหม่) -// - ใช้ร่วมกับ requirePerm() และ buildScopeWhere() -// - สำหรับจัดการ projects (ดู/เพิ่ม/แก้ไข/ลบ) ตามสิทธิ์ของผู้ใช้ -// Projects routes -// - CRUD operations for projects -// - Requires appropriate permissions via requirePerm middleware -// - Uses org/project scope for all permissions -// - project:read, project:create, project:update, project:delete -// - Project fields: project_id (PK), org_id (FK), project_code, project_name -// - project_code is unique -// - Basic validation: org_id, project_code, project_name required for create +// FILE: backend/src/routes/projects.js import { Router } from "express"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import { buildScopeWhere } from "../utils/scope.js"; const r = Router(); -// LIST – จำกัดตาม org/project scope ของผู้ใช้ +// LIST — ORG scope r.get( "/", - requirePerm("project.read", { scope: "global" }), + requirePerm("projects.view", { orgParam: "org_id" }), async (req, res) => { - const { where, params } = buildScopeWhere(req.principal, { - tableAlias: "p", - orgColumn: "p.org_id", - projectColumn: "p.project_id", - permCode: "project.read", - preferProject: true, - }); + const p = req.principal; + const { org_id } = req.query; + const params = []; + const cond = []; + + if (!p.is_superadmin) { + if (org_id) { + if (!p.inOrg(Number(org_id))) + return res.status(403).json({ error: "FORBIDDEN_ORG" }); + cond.push("p.org_id=?"); + params.push(Number(org_id)); + } else if (p.org_ids?.length) { + cond.push(`p.org_id IN (${p.org_ids.map(() => "?").join(",")})`); + params.push(...p.org_ids); + } + } else if (org_id) { + cond.push("p.org_id=?"); + params.push(Number(org_id)); + } + + const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; const [rows] = await sql.query( - `SELECT p.* FROM projects p WHERE ${where}`, + `SELECT p.* FROM projects p ${where} ORDER BY p.project_name`, params ); res.json(rows); } ); -// GET +// GET — PROJECT scope r.get( "/:id", - requirePerm("project.read", { - scope: "project", - getProjectId: async (req) => Number(req.params.id), - }), + requirePerm("projects.view", { orgParam: "org_id" }), async (req, res) => { const id = Number(req.params.id); const [[row]] = await sql.query( @@ -52,54 +51,71 @@ r.get( [id] ); if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inOrg(row.org_id)) + return res.status(403).json({ error: "FORBIDDEN_ORG" }); res.json(row); } ); -// CREATE +// CREATE — ORG scope r.post( "/", - requirePerm("project.create", { - scope: "org", - getOrgId: async (req) => req.body?.org_id ?? null, - }), + requirePerm("projects.manage", { orgParam: "org_id" }), async (req, res) => { - const { org_id, project_code, project_name } = req.body; + const { org_id, project_code, project_name } = req.body || {}; + if (!org_id || !project_code || !project_name) { + return res + .status(400) + .json({ error: "org_id, project_code, project_name required" }); + } const [rs] = await sql.query( - "INSERT INTO projects (org_id, project_code, project_name) VALUES (?,?,?)", - [org_id, project_code, project_name] + "INSERT INTO projects (org_id, project_code, project_name, created_by) VALUES (?,?,?,?)", + [Number(org_id), project_code, project_name, req.principal.user_id] ); - res.json({ project_id: rs.insertId }); + res.status(201).json({ project_id: rs.insertId }); } ); -// UPDATE +// UPDATE — ORG scope r.put( "/:id", - requirePerm("project.update", { - scope: "project", - getProjectId: async (req) => Number(req.params.id), - }), + requirePerm("projects.manage", { orgParam: "org_id" }), async (req, res) => { - const { project_name } = req.body; const id = Number(req.params.id); - await sql.query("UPDATE projects SET project_name=? WHERE project_id=?", [ - project_name, - id, - ]); + const [[row]] = await sql.query( + "SELECT * FROM projects WHERE project_id=?", + [id] + ); + if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inOrg(row.org_id)) + return res.status(403).json({ error: "FORBIDDEN_ORG" }); + + const { project_name } = req.body || {}; + await sql.query( + "UPDATE projects SET project_name=?, updated_by=? WHERE project_id=?", + [project_name ?? row.project_name, req.principal.user_id, id] + ); res.json({ ok: 1 }); } ); -// DELETE +// DELETE — ORG scope r.delete( "/:id", - requirePerm("project.delete", { - scope: "project", - getProjectId: async (req) => Number(req.params.id), - }), + requirePerm("projects.manage", { orgParam: "org_id" }), async (req, res) => { const id = Number(req.params.id); + const [[row]] = await sql.query( + "SELECT * FROM projects WHERE project_id=?", + [id] + ); + if (!row) return res.status(404).json({ error: "Not found" }); + const p = req.principal; + if (!p.is_superadmin && !p.inOrg(row.org_id)) + return res.status(403).json({ error: "FORBIDDEN_ORG" }); + await sql.query("DELETE FROM projects WHERE project_id=?", [id]); res.json({ ok: 1 }); } diff --git a/backend/src/routes/rbac_admin.js b/backend/src/routes/rbac_admin.js index 262f6f5d..991c5422 100644 --- a/backend/src/routes/rbac_admin.js +++ b/backend/src/routes/rbac_admin.js @@ -1,62 +1,47 @@ -// FILE: src/routes/rbac_admin.js -// RBAC Admin routes -// - Manage roles, permissions, user-role assignments -// - Requires appropriate permissions via requirePerm middleware -// - Uses global scope for all permissions -// - rbac_admin.read, rbac_admin.assign_role, rbac_admin.grant_perm - +// 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"; -import PERM from "../config/permissions.js"; const r = Router(); -/** LIST: roles */ -r.get( - "/roles", - requirePerm(PERM.rbac_admin.read, { scope: "global" }), - async (req, res) => { - const [rows] = await sql.query( - "SELECT role_id, role_code, role_name, description FROM roles ORDER BY role_code" - ); - res.json(rows); - } -); +// 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); +}); -/** LIST: permissions */ -r.get( - "/permissions", - requirePerm(PERM.rbac_admin.read, { scope: "global" }), - async (req, res) => { - const [rows] = await sql.query( - "SELECT permission_id, permission_code, description FROM permissions ORDER BY permission_code" - ); - res.json(rows); - } -); +// 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); +}); -/** LIST: role→permissions */ +// role -> permissions r.get( "/roles/:role_id/permissions", - requirePerm(PERM.rbac_admin.read, { scope: "global" }), + requirePerm("settings.manage"), async (req, res) => { const role_id = Number(req.params.role_id); const [rows] = await sql.query( - `SELECT p.permission_id, p.permission_code, p.description - FROM role_permissions rp - JOIN permissions p ON p.permission_id = rp.permission_id - WHERE rp.role_id=? ORDER BY p.permission_code`, + `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); } ); -/** MAP: role↔permission (grant/revoke) */ r.post( "/roles/:role_id/permissions", - requirePerm(PERM.rbac_admin.grant_perm, { scope: "global" }), + requirePerm("settings.manage"), async (req, res) => { const role_id = Number(req.params.role_id); const { permission_id } = req.body || {}; @@ -70,7 +55,7 @@ r.post( r.delete( "/roles/:role_id/permissions/:permission_id", - requirePerm(PERM.rbac_admin.grant_perm, { scope: "global" }), + requirePerm("settings.manage"), async (req, res) => { const role_id = Number(req.params.role_id); const permission_id = Number(req.params.permission_id); @@ -82,26 +67,25 @@ r.delete( } ); -/** LIST: user→roles(+scope) */ +// user -> roles (global/org/project scope columns มีในตาราง user_roles ตามสคีมา) r.get( "/users/:user_id/roles", - requirePerm(PERM.rbac_admin.read, { scope: "global" }), + 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`, + FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id + WHERE ur.user_id=? ORDER BY r.role_code`, [user_id] ); res.json(rows); } ); -/** MAP: user↔role(+scope) (assign / revoke) */ r.post( "/users/:user_id/roles", - requirePerm(PERM.rbac_admin.assign_role, { scope: "global" }), + 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 || {}; @@ -120,18 +104,20 @@ r.post( r.delete( "/users/:user_id/roles", - requirePerm(PERM.rbac_admin.assign_role, { scope: "global" }), + 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 WHERE user_id=? AND role_id=? AND <=> org_id ? AND <=> project_id ?" - .replace("<=> org_id ?", org_id === null ? "org_id IS ?" : "org_id=?") - .replace( - "<=> project_id ?", - project_id === null ? "project_id IS ?" : "project_id=?" - ), - [user_id, Number(role_id), org_id, project_id] + `DELETE FROM user_roles ur WHERE ur.user_id=? AND ur.role_id=? AND ${whereOrg} AND ${wherePrj}`, + params ); res.json({ ok: 1 }); } diff --git a/backend/src/routes/rfa.js b/backend/src/routes/rfa.js index 0c47b38f..ad61a257 100644 --- a/backend/src/routes/rfa.js +++ b/backend/src/routes/rfa.js @@ -1,23 +1,15 @@ -// FILE: src/routes/rfa.js -// RFA routes -// - POST /create to create a new RFA with optional associated item documents -// - POST /update-status to update the status of an existing RFA -// - Requires authentication and appropriate permissions via requireAuth and requirePermission middleware -// - Uses project scope for permissions -// - RFA_CREATE permission required for creating RFAs -// - RFA_STATUS_UPDATE permission required for updating RFA status - +// FILE: backend/src/routes/rfa.js +// RFA: create + update-status ผ่าน stored procedures import { Router } from "express"; -import { requireAuth } from "../middleware/auth.js"; -import { requirePermission } from "../middleware/perm.js"; -import { callProc } from "../db/index.js"; +import sql, { callProc } from "../db/index.js"; +import { requirePerm } from "../middleware/requirePerm.js"; -const router = Router(); +const r = Router(); -router.post( +// CREATE (PROJECT scope) -> rfas.create +r.post( "/create", - requireAuth, - requirePermission(["RFA_CREATE"], { projectRequired: true }), + requirePerm("rfas.create", { projectParam: "project_id" }), async (req, res, next) => { try { const { @@ -31,20 +23,26 @@ router.post( pdf_path = null, item_doc_ids = [], } = req.body || {}; - const json = JSON.stringify(item_doc_ids.map(Number)); + + if (!project_id || !title) { + return res.status(400).json({ error: "project_id and title required" }); + } + + const json = JSON.stringify((item_doc_ids || []).map(Number)); await callProc("sp_rfa_create_with_items", [ - req.user.user_id, + req.principal.user_id, project_id, - cor_status_id, - cor_no, + cor_status_id ?? null, + cor_no ?? null, title, - originator_id, - recipient_id, + originator_id ?? null, + recipient_id ?? null, keywords, pdf_path, json, null, ]); + res.status(201).json({ ok: true }); } catch (e) { next(e); @@ -52,15 +50,33 @@ router.post( } ); -router.post( +// UPDATE STATUS (PROJECT scope) -> rfas.respond +r.post( "/update-status", - requireAuth, - requirePermission(["RFA_STATUS_UPDATE"], { projectRequired: true }), + requirePerm("rfas.respond"), async (req, res, next) => { try { const { rfa_corr_id, status_id, set_issue = 0 } = req.body || {}; + if (!rfa_corr_id || !status_id) { + return res + .status(400) + .json({ error: "rfa_corr_id and status_id required" }); + } + // enforce ABAC: find project_id of the RFA + const [[ref]] = await sql.query( + "SELECT project_id FROM rfas WHERE id=? LIMIT 1", + [Number(rfa_corr_id)] + ); + if (!ref) return res.status(404).json({ error: "RFA not found" }); + if ( + !req.principal.is_superadmin && + !req.principal.inProject(ref.project_id) + ) { + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + } + await callProc("sp_rfa_update_status", [ - req.user.user_id, + req.principal.user_id, rfa_corr_id, status_id, set_issue ? 1 : 0, @@ -72,4 +88,4 @@ router.post( } ); -export default router; +export default r; diff --git a/backend/src/routes/rfas.js b/backend/src/routes/rfas.js index c92cacfc..34cbe660 100644 --- a/backend/src/routes/rfas.js +++ b/backend/src/routes/rfas.js @@ -1,270 +1,167 @@ -// FILE: src/routes/rfas.js -// 03.2 8) แก้ไข routes/rfas.js (ใหม่) -// - ผสมผสานระหว่าง rfas.js เดิม + ฟีเจอร์ list/sort/paging/overdue จาก rfas-1.js -// - ใช้ร่วมกับ requirePerm() และ buildScopeWhere() -// - สำหรับจัดการ RFAs (ดู/เพิ่ม/แก้ไข/ลบ) ตามสิทธิ์ของผู้ใช้ -// RFAs routes -// - Enhanced version of rfas.js with list/sort/paging/overdue from rfas-1.js -// - Requires appropriate permissions via requirePerm middleware -// - Uses project scope for rfa.read, org scope for rfa.create/update/delete -// - GET /api/rfas for listing with faceted filters, sorting, and paging -// - GET /api/rfas/:id for fetching a single RFA -// - POST /api/rfas for creating a new RFA -// - PUT /api/rfas/:id for updating an existing RFA (full update) -// - PATCH /api/rfas/:id for partial updates -// - DELETE /api/rfas/:id for deleting an RFA - -// Base: rfas.js + enhanced list/sort/paging/overdue from rfas-1.js - +// FILE: backend/src/routes/rfas.js +// RFAs list/get/create/update/delete — มาตรฐาน Bearer + requirePerm import { Router } from "express"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import { buildScopeWhere, ownerResolvers } from "../utils/scope.js"; -// import PERM from '../config/permissions.js'; // ถ้าไม่ได้ใช้ สามารถลบได้ const r = Router(); -const OWN = ownerResolvers(sql, "rfas", "id"); -/* ----------------------------- Utilities ----------------------------- */ -// Allow-list สำหรับการ sort ป้องกัน SQL injection const ALLOWED_SORT = new Map([ ["updated_at", "updated_at"], ["due_date", "due_date"], ["created_at", "created_at"], ["id", "id"], ]); - function parseSort(sort = "updated_at:desc") { - const [colRaw, dirRaw] = String(sort).split(":"); - const col = ALLOWED_SORT.get(colRaw) || "updated_at"; - const dir = (dirRaw || "desc").toLowerCase() === "asc" ? "ASC" : "DESC"; + const [c, d] = String(sort).split(":"); + const col = ALLOWED_SORT.get(c) || "updated_at"; + const dir = (d || "desc").toLowerCase() === "asc" ? "ASC" : "DESC"; return `\`${col}\` ${dir}`; } - -function parsePaging({ page = 1, pageSize = 20 }) { +function paging({ page = 1, pageSize = 20 }) { const p = Math.max(1, Number(page) || 1); const ps = Math.min(200, Math.max(1, Number(pageSize) || 20)); return { limit: ps, offset: (p - 1) * ps, page: p, pageSize: ps }; } -// ตัวกรองเพิ่มเติม (จาก rfas-1.js) + ผสมกับเงื่อนไข scope เดิม -function buildExtraFilters({ q, status, overdue, project_id, org_id }) { - const parts = []; - const params = {}; - if (project_id) { - parts.push("r.project_id = :project_id"); - params.project_id = Number(project_id); - } - if (org_id) { - parts.push("r.org_id = :org_id"); - params.org_id = Number(org_id); - } - if (status) { - parts.push("r.status = :status"); - params.status = status; - } - if (q) { - parts.push("(r.rfa_no LIKE :q OR r.title LIKE :q OR r.code LIKE :q)"); - params.q = `%${q}%`; - } - if (String(overdue) === "1") { - // overdue: due_date < TODAY และสถานะยังไม่ปิด - parts.push( - "r.due_date IS NOT NULL AND r.due_date < CURDATE() AND r.status NOT IN ('Closed','Approved')" - ); - } - return { where: parts.join(" AND "), params }; -} - -/* -------------------------------- LIST -------------------------------- - GET /rfas - - คง requirePerm แบบ rfas.js (scope:global + project/org scope ผ่าน buildScopeWhere) - - เพิ่ม faceted filters/sort/paging/overdue จาก rfas-1.js -------------------------------------------------------------------------*/ -r.get("/", requirePerm("rfa.read", { scope: "global" }), async (req, res) => { - try { - const { q, status, overdue, sort, page, pageSize, project_id, org_id } = - req.query; +// LIST (PROJECT scope enforced: filter ด้วย principal) +r.get( + "/", + requirePerm("rfas.view", { projectParam: "project_id" }), + async (req, res) => { + const { q, status, overdue, sort, page, pageSize, project_id } = req.query; const orderBy = parseSort(sort); - const { - limit, - offset, - page: p, - pageSize: ps, - } = parsePaging({ page, pageSize }); + const { limit, offset, page: p, pageSize: ps } = paging({ page, pageSize }); - // base scope จาก principal (org/project) - const base = buildScopeWhere(req.principal, { - tableAlias: "r", - orgColumn: "r.org_id", - projectColumn: "r.project_id", - permCode: "rfa.read", - preferProject: true, - }); + const P = req.principal; + const cond = []; + const params = []; - // extra filters - const extra = buildExtraFilters({ q, status, overdue, project_id, org_id }); + if (!P.is_superadmin) { + if (project_id) { + const pid = Number(project_id); + if (!P.inProject(pid)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + cond.push("r.project_id=?"); + params.push(pid); + } else if (P.project_ids?.length) { + cond.push( + `r.project_id IN (${P.project_ids.map(() => "?").join(",")})` + ); + params.push(...P.project_ids); + } + } else if (project_id) { + cond.push("r.project_id=?"); + params.push(Number(project_id)); + } - // รวม where - const where = - [base.where, extra.where].filter(Boolean).join(" AND ") || "1=1"; - const params = { ...base.params, ...extra.params, limit, offset }; + if (status) { + cond.push("r.status=?"); + params.push(status); + } + if (q) { + cond.push("(r.rfa_no LIKE ? OR r.title LIKE ? OR r.code LIKE ?)"); + params.push(`%${q}%`, `%${q}%`, `%${q}%`); + } + if (String(overdue) === "1") { + cond.push( + "r.due_date IS NOT NULL AND r.due_date < CURDATE() AND r.status NOT IN ('Closed','Approved')" + ); + } + const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; - // total - const [[{ cnt: total }]] = await sql.query( - `SELECT COUNT(*) AS cnt FROM rfas r WHERE ${where}`, + const [[{ cnt }]] = await sql.query( + `SELECT COUNT(*) AS cnt FROM rfas r ${where}`, params ); - - // rows const [rows] = await sql.query( - `SELECT r.id, r.code, r.rfa_no, r.title, r.status, r.discipline, r.due_date, r.owner_id, r.updated_at, r.project_id, r.org_id - FROM rfas r - WHERE ${where} - ORDER BY ${orderBy} - LIMIT :limit OFFSET :offset`, - params + `SELECT r.id, r.code, r.rfa_no, r.title, r.status, r.discipline, r.due_date, r.updated_at, r.project_id + FROM rfas r ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`, + [...params, limit, offset] ); - - res.json({ data: rows, total: Number(total || 0), page: p, pageSize: ps }); - } catch (e) { - res.status(500).json({ error: e.message || "rfas/list failed" }); + res.json({ data: rows, total: Number(cnt || 0), page: p, pageSize: ps }); } +); + +// GET ONE +r.get("/:id", requirePerm("rfas.view"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM rfas WHERE id=?", [id]); + if (!row) return res.status(404).json({ error: "Not found" }); + const P = req.principal; + if (!P.is_superadmin && !P.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + res.json(row); }); -/* ------------------------------- GET ONE ------------------------------ -// ยึดรูปแบบตรวจสิทธิ์จาก rfas.js -------------------------------------------------------------------------*/ -r.get( - "/:id", - requirePerm("rfa.read", { scope: "org", getOrgId: OWN.getOrgIdById }), - async (req, res) => { - try { - const id = Number(req.params.id); - const [[row]] = await sql.query("SELECT * FROM rfas WHERE id=?", [id]); - if (!row) return res.status(404).json({ error: "Not found" }); - res.json(row); - } catch (e) { - res.status(500).json({ error: e.message || "rfas/detail failed" }); - } - } -); - -/* ------------------------------- CREATE ------------------------------- -// ยึดรูปแบบฟิลด์ของ rfas.js (org_id, project_id, rfa_no, title, status) -// เพิ่ม validation เบื้องต้น (title required) -------------------------------------------------------------------------*/ +// CREATE r.post( "/", - requirePerm("rfa.create", { - scope: "org", - getOrgId: async (req) => req.body?.org_id ?? null, - }), + requirePerm("rfas.create", { projectParam: "project_id" }), async (req, res) => { - try { - const { org_id, project_id, rfa_no, title, status } = req.body || {}; - if (!title?.trim()) - return res.status(400).json({ error: "title is required" }); - - const st = String(status || "").trim() || "draft"; - const [rs] = await sql.query( - `INSERT INTO rfas (org_id, project_id, rfa_no, title, status, created_by, created_at, updated_at) - VALUES (?,?,?,?,?,?,NOW(),NOW())`, - [org_id, project_id, rfa_no, title, st, req.principal.userId] - ); - res.status(201).json({ id: rs.insertId }); - } catch (e) { - res.status(500).json({ error: e.message || "rfas/create failed" }); - } + const { org_id, project_id, rfa_no, title, status } = req.body || {}; + if (!project_id || !title) + return res.status(400).json({ error: "project_id and title required" }); + const [rs] = await sql.query( + `INSERT INTO rfas (org_id, project_id, rfa_no, title, status, created_by, created_at, updated_at) + VALUES (?,?,?,?,?,?,NOW(),NOW())`, + [ + org_id ?? null, + project_id, + rfa_no ?? null, + title, + status ?? "draft", + req.principal.user_id, + ] + ); + res.status(201).json({ id: rs.insertId }); } ); -/* ------------------------------- UPDATE ------------------------------- -// PUT: รูปแบบเดิมของ rfas.js (อัปเดต title, status) -// PATCH: แบบยืดหยุ่น (บางฟิลด์) ผสานแนวทางจาก rfas-1.js -------------------------------------------------------------------------*/ -r.put( - "/:id", - requirePerm("rfa.update", { scope: "org", getOrgId: OWN.getOrgIdById }), - async (req, res) => { - try { - const id = Number(req.params.id); - const { title, status } = req.body || {}; - await sql.query( - "UPDATE rfas SET title=?, status=?, updated_at=NOW() WHERE id=?", - [title, status, id] - ); - res.json({ ok: 1, id }); - } catch (e) { - res.status(500).json({ error: e.message || "rfas/update failed" }); - } - } -); +// UPDATE (respond/edit) +r.patch("/:id", requirePerm("rfas.respond"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM rfas WHERE id=?", [id]); + if (!row) return res.status(404).json({ error: "Not found" }); + const P = req.principal; + if (!P.is_superadmin && !P.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); -// PATCH แบบ partial fields -r.patch( - "/:id", - requirePerm("rfa.update", { scope: "org", getOrgId: OWN.getOrgIdById }), - async (req, res) => { - try { - const id = Number(req.params.id); - const allowed = [ - "code", - "rfa_no", - "title", - "discipline", - "due_date", - "description", - "status", - "owner_id", - ]; - const patch = {}; - for (const k of allowed) if (k in req.body) patch[k] = req.body[k]; + const allowed = [ + "code", + "rfa_no", + "title", + "discipline", + "due_date", + "description", + "status", + "owner_id", + ]; + const patch = {}; + for (const k of allowed) if (k in req.body) patch[k] = req.body[k]; + if (!Object.keys(patch).length) + return res.status(400).json({ error: "no fields to update" }); - if (Object.keys(patch).length === 0) { - return res.status(400).json({ error: "no fields to update" }); - } + const sets = Object.keys(patch).map((k) => `\`${k}\`=?`); + await sql.query( + `UPDATE rfas SET ${sets.join(", ")}, updated_at=NOW() WHERE id=?`, + [...Object.values(patch), id] + ); + res.json({ ok: 1, id }); +}); - if ("status" in patch) { - const s = String(patch.status); - const ok = [ - "draft", - "submitted", - "Pending", - "Review", - "Approved", - "Closed", - ].includes(s); - if (!ok) return res.status(400).json({ error: "invalid status" }); - } - - const sets = Object.keys(patch).map((k) => `\`${k}\` = :${k}`); - patch.id = id; - - await sql.query( - `UPDATE rfas SET ${sets.join(", ")}, updated_at=NOW() WHERE id=:id`, - patch - ); - res.json({ ok: 1, id }); - } catch (e) { - res.status(500).json({ error: e.message || "rfas/patch failed" }); - } - } -); - -/* ------------------------------- DELETE ------------------------------- */ -r.delete( - "/:id", - requirePerm("rfa.delete", { scope: "org", getOrgId: OWN.getOrgIdById }), - async (req, res) => { - try { - const id = Number(req.params.id); - await sql.query("DELETE FROM rfas WHERE id=?", [id]); - res.json({ ok: 1, id }); - } catch (e) { - res.status(500).json({ error: e.message || "rfas/delete failed" }); - } - } -); +// DELETE +r.delete("/:id", requirePerm("rfas.delete"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT project_id FROM rfas WHERE id=?", [ + id, + ]); + if (!row) return res.status(404).json({ error: "Not found" }); + const P = req.principal; + if (!P.is_superadmin && !P.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + await sql.query("DELETE FROM rfas WHERE id=?", [id]); + res.json({ ok: 1 }); +}); export default r; diff --git a/backend/src/routes/subcategories.js b/backend/src/routes/subcategories.js index 5af550c0..47a1176c 100644 --- a/backend/src/routes/subcategories.js +++ b/backend/src/routes/subcategories.js @@ -1,91 +1,93 @@ -// FILE: src/routes/subcategories.js -// Subcategories routes -// - CRUD operations for subcategories -// - Requires appropriate permissions via requirePerm middleware -// - Uses project scope for all permissions -// - lookup:edit -// - Subcategory fields: sub_cat_id (PK), project_id (FK), sub_cat_name, parent_cat_id (FK), code -// - Basic validation: project_id, sub_cat_name required for create - +// FILE: backend/src/routes/subcategories.js +// Master data: subcategories — GLOBAL read/write (ตาม categories.js) import { Router } from "express"; -import { requireAuth } from "../middleware/auth.js"; -import { enrichPermissions } from "../middleware/permissions.js"; -import { requireRole } from "../middleware/rbac.js"; -import { requirePerm } from "../middleware/permGuard.js"; -import { sequelize } from "../db/sequelize.js"; -import SubCatModel from "../db/models/SubCategory.js"; +import sql from "../db/index.js"; +import { requirePerm } from "../middleware/requirePerm.js"; const r = Router(); -const SubCat = SubCatModel(sequelize); -r.get("/sub_categories", requireAuth, async (req, res) => { - const { q, project_id, page = 1, page_size = 50 } = req.query; - const limit = Math.min(Number(page_size) || 50, 200); - const offset = (Math.max(Number(page) || 1, 1) - 1) * limit; - const where = {}; - if (project_id) where.project_id = project_id; - if (q) - where.sub_cat_name = sequelize.where( - sequelize.fn("LOWER", sequelize.col("sub_cat_name")), - "LIKE", - `%${String(q).toLowerCase()}%` - ); - const { rows, count } = await SubCat.findAndCountAll({ - where, - limit, - offset, - order: [["sub_cat_name", "ASC"]], - }); - res.json({ items: rows, total: count, page: Number(page), page_size: limit }); -}); - -r.post( +// LIST (GLOBAL read) +r.get( "/sub_categories", - requireAuth, - enrichPermissions(), - requirePerm("lookup:edit"), + requirePerm("organizations.view"), async (req, res) => { - const { project_id, sub_cat_name, parent_cat_id, code } = req.body || {}; - if (!project_id || !sub_cat_name) - return res - .status(400) - .json({ error: "project_id and sub_cat_name required" }); - const created = await SubCat.create({ - project_id, - sub_cat_name, - parent_cat_id, - code, + const { q, cat_id, page = 1, page_size = 50 } = req.query; + const limit = Math.min(Number(page_size) || 50, 200); + const offset = (Math.max(Number(page) || 1, 1) - 1) * limit; + + const cond = []; + const params = []; + if (cat_id) { + cond.push("cat_id=?"); + params.push(Number(cat_id)); + } + if (q) { + cond.push("LOWER(sub_cat_name) LIKE ?"); + params.push(`%${String(q).toLowerCase()}%`); + } + + const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; + const [[{ cnt }]] = await sql.query( + `SELECT COUNT(*) AS cnt FROM subcategories ${where}`, + params + ); + const [rows] = await sql.query( + `SELECT * FROM subcategories ${where} ORDER BY sub_cat_name ASC LIMIT ? OFFSET ?`, + [...params, limit, offset] + ); + res.json({ + items: rows, + total: Number(cnt || 0), + page: Number(page) || 1, + page_size: limit, }); - res.status(201).json({ sub_cat_id: created.sub_cat_id }); } ); +// CREATE (GLOBAL write) +r.post("/sub_categories", requirePerm("settings.manage"), async (req, res) => { + const { cat_id, sub_cat_name, code } = req.body || {}; + if (!cat_id || !sub_cat_name) + return res.status(400).json({ error: "cat_id and sub_cat_name required" }); + const [rs] = await sql.query( + "INSERT INTO subcategories (cat_id, sub_cat_name, code) VALUES (?,?,?)", + [Number(cat_id), sub_cat_name, code ?? null] + ); + res.status(201).json({ sub_cat_id: rs.insertId }); +}); + +// UPDATE r.patch( "/sub_categories/:id", - requireAuth, - enrichPermissions(), - requirePerm("lookup:edit"), + requirePerm("settings.manage"), async (req, res) => { - const row = await SubCat.findByPk(Number(req.params.id)); + const id = Number(req.params.id); + const [[row]] = await sql.query( + "SELECT * FROM subcategories WHERE sub_cat_id=?", + [id] + ); if (!row) return res.status(404).json({ error: "Not found" }); - const { sub_cat_name, parent_cat_id, code } = req.body || {}; - if (sub_cat_name !== undefined) row.sub_cat_name = sub_cat_name; - if (parent_cat_id !== undefined) row.parent_cat_id = parent_cat_id; - if (code !== undefined) row.code = code; - await row.save(); + const { sub_cat_name, cat_id, code } = req.body || {}; + await sql.query( + "UPDATE subcategories SET sub_cat_name=?, cat_id=?, code=? WHERE sub_cat_id=?", + [ + sub_cat_name ?? row.sub_cat_name, + cat_id ?? row.cat_id, + code ?? row.code, + id, + ] + ); res.json({ ok: true }); } ); +// DELETE r.delete( "/sub_categories/:id", - requireAuth, - enrichPermissions(), - requirePerm("lookup:edit"), + requirePerm("settings.manage"), async (req, res) => { - const row = await SubCat.findByPk(Number(req.params.id)); - if (!row) return res.status(404).json({ error: "Not found" }); - await row.destroy(); + const id = Number(req.params.id); + await sql.query("DELETE FROM subcategories WHERE sub_cat_id=?", [id]); res.json({ ok: true }); } ); diff --git a/backend/src/routes/technicaldocs.js b/backend/src/routes/technicaldocs.js index 1ed3190c..e3f11d68 100644 --- a/backend/src/routes/technicaldocs.js +++ b/backend/src/routes/technicaldocs.js @@ -1,222 +1,124 @@ -// FILE: src/routes/technicaldocs.js -// Technical Documents routes -// - CRUD operations for technical documents -// - Requires appropriate permissions via requirePerm middleware -// - Supports filtering and pagination on list endpoint -// - Uses ownerResolvers utility to determine org ownership for permission checks -// - Permissions required are defined in config/permissions.js -// - technicaldoc.read -// - technicaldoc.create -// - technicaldoc.update -// - technicaldoc.delete -// - Scope can be 'global' (list), 'org' (get/create/update/delete) -// - List endpoint supports filtering by project_id, org_id, status, and search query (q) -// - Pagination via limit and offset query parameters -// - Results ordered by id DESC -// - Error handling for not found and no fields to update scenarios -// - Uses async/await for asynchronous operations -// - SQL queries use parameterized queries to prevent SQL injection -// - Responses are in JSON format -// - Middleware functions are used for permission checks -// - Owner resolvers are used to fetch org_id for specific document ids -// - Code is modular and organized for maintainability -// - Comments are provided for clarity/documentation -// - Follows best practices for Express.js route handling -// - Uses ES6+ features for cleaner code -// - Assumes existence of technicaldocs table with appropriate columns -// - Assumes existence of users table for created_by field -// - Assumes existence of config/permissions.js with defined permission codes -// - Assumes existence of utils/scope.js with buildScopeWhere and ownerResolvers functions -// - Assumes existence of middleware/requirePerm.js for permission checks -// - Assumes existence of db/index.js for database connection/querying -// - Assumes Express.js app is set up to use this router for /api/technicaldocs path - -import { Router } from 'express'; -import sql from '../db/index.js'; -import { requirePerm } from '../middleware/requirePerm.js'; -import { buildScopeWhere, ownerResolvers } from '../utils/scope.js'; -import PERM from '../config/permissions.js'; +// FILE: backend/src/routes/technicaldocs.js +// แมปเป็นเอกสารประเภทหนึ่ง → ใช้สิทธิ์ documents.view/manage (PROJECT) +import { Router } from "express"; +import sql from "../db/index.js"; +import { requirePerm } from "../middleware/requirePerm.js"; const r = Router(); -const OWN = ownerResolvers(sql, 'technicaldocs', 'id'); - -// LIST (filter + scope) -r.get('/', - requirePerm(PERM.technicaldoc.read, { scope: 'global' }), - async (req, res) => { - const { project_id, org_id, status, q, limit = 50, offset = 0 } = req.query; - - const base = buildScopeWhere(req.principal, { - tableAlias: 't', - orgColumn: 't.org_id',import { Router } from 'express'; -import sql from '../db/index.js'; -import { requirePerm } from '../middleware/requirePerm.js'; -import { buildScopeWhere, ownerResolvers } from '../utils/scope.js'; -import PERM from '../config/permissions.js'; - -const r = Router(); -const OWN = ownerResolvers(sql, 'transmittals', 'id'); // LIST -r.get('/', - requirePerm(PERM.transmittal.read, { scope: 'global' }), +r.get( + "/", + requirePerm("documents.view", { projectParam: "project_id" }), async (req, res) => { - const { project_id, org_id, tr_no, q, limit = 50, offset = 0 } = req.query; - - const base = buildScopeWhere(req.principal, { - tableAlias: 't', - orgColumn: 't.org_id', - projectColumn: 't.project_id', - permCode: PERM.transmittal.read, - preferProject: true, - }); - - const extra = []; - const params = { ...base.params, limit: Number(limit), offset: Number(offset) }; - if (project_id) { extra.push('t.project_id = :project_id'); params.project_id = Number(project_id); } - if (org_id) { extra.push('t.org_id = :org_id'); params.org_id = Number(org_id); } - if (tr_no) { extra.push('t.tr_no = :tr_no'); params.tr_no = tr_no; } - if (q) { extra.push('(t.tr_no LIKE :q OR t.subject LIKE :q)'); params.q = `%${q}%`; } - - const where = [base.where, ...extra].filter(Boolean).join(' AND '); - - const [rows] = await sql.query( - `SELECT t.* FROM transmittals t - WHERE ${where} - ORDER BY t.id DESC - LIMIT :limit OFFSET :offset`, - params - ); - res.json(rows); - } -); - -// GET -r.get('/:id', - requirePerm(PERM.transmittal.read, { scope: 'org', getOrgId: OWN.getOrgIdById }), - async (req, res) => { - const id = Number(req.params.id); - const [[row]] = await sql.query('SELECT * FROM transmittals WHERE id=?', [id]); - if (!row) return res.status(404).json({ error: 'Not found' }); - res.json(row); - } -); - -// CREATE -r.post('/', - requirePerm(PERM.transmittal.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }), - async (req, res) => { - const { org_id, project_id, tr_no, subject, status } = req.body; - const [rs] = await sql.query( - `INSERT INTO transmittals (org_id, project_id, tr_no, subject, status, created_by) - VALUES (?,?,?,?,?,?)`, - [org_id, project_id, tr_no, subject, status, req.principal.userId] - ); - res.json({ id: rs.insertId }); - } -); - -// UPDATE (รองรับ PATCH) -r.patch('/:id', - requirePerm(PERM.transmittal.update, { scope: 'org', getOrgId: OWN.getOrgIdById }), - async (req, res) => { - const id = Number(req.params.id); - const fields = []; + const { project_id, status, q, limit = 50, offset = 0 } = req.query; + const P = req.principal; + const cond = []; const params = []; - - // อนุญาตแก้ฟิลด์หลัก - const allow = ['tr_no','subject','status']; - for (const k of allow) { - if (k in req.body) { - fields.push(`${k} = ?`); - params.push(req.body[k]); + if (!P.is_superadmin) { + if (project_id) { + const pid = Number(project_id); + if (!P.inProject(pid)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + cond.push("t.project_id=?"); + params.push(pid); + } else if (P.project_ids?.length) { + cond.push( + `t.project_id IN (${P.project_ids.map(() => "?").join(",")})` + ); + params.push(...P.project_ids); } + } else if (project_id) { + cond.push("t.project_id=?"); + params.push(Number(project_id)); } - if (!fields.length) return res.status(400).json({ error: 'NO_FIELDS' }); - params.push(id); - await sql.query(`UPDATE transmittals SET ${fields.join(', ')} WHERE id=?`, params); - res.json({ ok: 1 }); - } -); + if (status) { + cond.push("t.status=?"); + params.push(status); + } + if (q) { + cond.push("(t.doc_no LIKE ? OR t.title LIKE ?)"); + params.push(`%${q}%`, `%${q}%`); + } -// DELETE -r.delete('/:id', - requirePerm(PERM.transmittal.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }), - async (req, res) => { - const id = Number(req.params.id); - await sql.query('DELETE FROM transmittals WHERE id=?', [id]); - res.json({ ok: 1 }); - } -); - -export default r; - - projectColumn: 't.project_id', - permCode: PERM.technicaldoc.read, - preferProject: true, - }); - - const extra = []; - const params = { ...base.params, limit: Number(limit), offset: Number(offset) }; - if (project_id) { extra.push('t.project_id = :project_id'); params.project_id = Number(project_id); } - if (org_id) { extra.push('t.org_id = :org_id'); params.org_id = Number(org_id); } - if (status) { extra.push('t.status = :status'); params.status = status; } - if (q) { extra.push('(t.doc_no LIKE :q OR t.title LIKE :q)'); params.q = `%${q}%`; } - - const where = [base.where, ...extra].filter(Boolean).join(' AND '); + const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; const [rows] = await sql.query( - `SELECT t.* FROM technicaldocs t WHERE ${where} - ORDER BY t.id DESC LIMIT :limit OFFSET :offset`, params + `SELECT t.* FROM technicaldocs t ${where} ORDER BY t.id DESC LIMIT ? OFFSET ?`, + [...params, Number(limit), Number(offset)] ); res.json(rows); } ); // GET -r.get('/:id', - requirePerm(PERM.technicaldoc.read, { scope: 'org', getOrgId: OWN.getOrgIdById }), - async (req, res) => { - const id = Number(req.params.id); - const [[row]] = await sql.query('SELECT * FROM technicaldocs WHERE id=?', [id]); - if (!row) return res.status(404).json({ error: 'Not found' }); - res.json(row); - } -); +r.get("/:id", requirePerm("documents.view"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM technicaldocs WHERE id=?", [ + id, + ]); + if (!row) return res.status(404).json({ error: "Not found" }); + const P = req.principal; + if (!P.is_superadmin && !P.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + res.json(row); +}); // CREATE -r.post('/', - requirePerm(PERM.technicaldoc.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }), +r.post( + "/", + requirePerm("documents.manage", { projectParam: "project_id" }), async (req, res) => { - const { org_id, project_id, doc_no, title, status } = req.body; + const { org_id, project_id, doc_no, title, status } = req.body || {}; + if (!project_id || !doc_no) + return res.status(400).json({ error: "project_id and doc_no required" }); const [rs] = await sql.query( `INSERT INTO technicaldocs (org_id, project_id, doc_no, title, status, created_by) - VALUES (?,?,?,?,?,?)`, - [org_id, project_id, doc_no, title, status, req.principal.userId] + VALUES (?,?,?,?,?,?)`, + [ + org_id ?? null, + project_id, + doc_no, + title ?? null, + status ?? null, + req.principal.user_id, + ] ); - res.json({ id: rs.insertId }); + res.status(201).json({ id: rs.insertId }); } ); // UPDATE -r.put('/:id', - requirePerm(PERM.technicaldoc.update, { scope: 'org', getOrgId: OWN.getOrgIdById }), - async (req, res) => { - const id = Number(req.params.id); - const { title, status } = req.body; - await sql.query('UPDATE technicaldocs SET title=?, status=? WHERE id=?', [title, status, id]); - res.json({ ok: 1 }); - } -); +r.put("/:id", requirePerm("documents.manage"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM technicaldocs WHERE id=?", [ + id, + ]); + if (!row) return res.status(404).json({ error: "Not found" }); + const P = req.principal; + if (!P.is_superadmin && !P.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + const { title, status } = req.body || {}; + await sql.query("UPDATE technicaldocs SET title=?, status=? WHERE id=?", [ + title ?? row.title, + status ?? row.status, + id, + ]); + res.json({ ok: 1 }); +}); // DELETE -r.delete('/:id', - requirePerm(PERM.technicaldoc.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }), - async (req, res) => { - const id = Number(req.params.id); - await sql.query('DELETE FROM technicaldocs WHERE id=?', [id]); - res.json({ ok: 1 }); - } -); +r.delete("/:id", requirePerm("documents.manage"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM technicaldocs WHERE id=?", [ + id, + ]); + if (!row) return res.status(404).json({ error: "Not found" }); + const P = req.principal; + if (!P.is_superadmin && !P.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + await sql.query("DELETE FROM technicaldocs WHERE id=?", [id]); + res.json({ ok: 1 }); +}); export default r; diff --git a/backend/src/routes/transmittals.js b/backend/src/routes/transmittals.js index 143a2650..e65febb0 100644 --- a/backend/src/routes/transmittals.js +++ b/backend/src/routes/transmittals.js @@ -1,281 +1,131 @@ -// FILE: src/routes/transmittals.js -// Transmittals routes -// - Enhanced version of transmittals.js with list/sort/paging from transmittals-1.js -// - Supports GET /transmittals with filtering, sorting, and pagination -// - Requires appropriate permissions via requirePerm middleware -// - GET by id, POST (create), PUT (update), PATCH (partial update), DELETE -// - RBAC/Scope -// - Global scope: list all transmittals user has access to (project/org scope applied) -// - Org scope: get/create/update/delete transmittals within a specific org -// - Permissions required: -// - transmittal.read (global/org) for -// - GET /transmittals (list) -// - GET /transmittals/:id (get by id) - -// Base: transmittals.js + list/sort/paging from transmittals-1.js -// Notes: -// - ยึด RBAC/Scope/Permissions แบบเดิมของ transmittals.js -// - Faceted list -> ส่ง meta { data, total, page, pageSize } -// - PATCH: อัปเดตบางฟิลด์อย่างปลอดภัย (ไม่บังคับให้มีคอลัมน์ใหม่ใน DB) - +// FILE: backend/src/routes/transmittals.js +// ทั้งโมดูลใช้สิทธิ์เดียว: transmittals.manage (PROJECT) import { Router } from "express"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import { buildScopeWhere, ownerResolvers } from "../utils/scope.js"; -import PERM from "../config/permissions.js"; const r = Router(); -const OWN = ownerResolvers(sql, "transmittals", "id"); -/* ----------------------------- Utilities ----------------------------- */ -// จำกัดฟิลด์ที่อนุญาตให้ sort เพื่อลดความเสี่ยง SQLi -const ALLOWED_SORT = new Map([ - ["updated_at", "updated_at"], - ["created_at", "created_at"], - ["id", "id"], - ["tr_no", "tr_no"], - ["subject", "subject"], -]); -function parseSort(sort = "updated_at:desc") { - const [colRaw, dirRaw] = String(sort).split(":"); - const col = ALLOWED_SORT.get(colRaw) || "updated_at"; - const dir = (dirRaw || "desc").toLowerCase() === "asc" ? "ASC" : "DESC"; - return `\`${col}\` ${dir}`; -} -function parsePaging({ page = 1, pageSize = 20 }) { - const p = Math.max(1, Number(page) || 1); - const ps = Math.min(200, Math.max(1, Number(pageSize) || 20)); - return { limit: ps, offset: (p - 1) * ps, page: p, pageSize: ps }; -} - -function buildExtraFilters({ project_id, org_id, tr_no, q }) { - const extra = []; - const params = {}; - if (project_id) { - extra.push("t.project_id = :project_id"); - params.project_id = Number(project_id); - } - if (org_id) { - extra.push("t.org_id = :org_id"); - params.org_id = Number(org_id); - } - if (tr_no) { - extra.push("t.tr_no = :tr_no"); - params.tr_no = tr_no; - } - if (q) { - // ใช้ฟิลด์พื้นฐานที่ transmittals.js มีแน่นอน (tr_no, subject) - extra.push("(t.tr_no LIKE :q OR t.subject LIKE :q)"); - params.q = `%${q}%`; - } - return { where: extra.join(" AND "), params }; -} - -/* -------------------------------- LIST -------------------------------- -GET /transmittals -- คง RBAC/Scope เดิม (global + project/org scope ผ่าน buildScopeWhere) -- เพิ่ม sort/page/pageSize/q ตามสไตล์ transmittals-1.js และตอบ meta -------------------------------------------------------------------------*/ +// LIST r.get( "/", - requirePerm(PERM.transmittal.read, { scope: "global" }), + requirePerm("transmittals.manage", { projectParam: "project_id" }), async (req, res) => { - try { - const { project_id, org_id, tr_no, q, sort, page, pageSize } = req.query; - const orderBy = parseSort(sort); - const { - limit, - offset, - page: p, - pageSize: ps, - } = parsePaging({ page, pageSize }); + const { project_id, tr_no, q, limit = 50, offset = 0 } = req.query; + const P = req.principal; + const cond = []; + const params = []; - const base = buildScopeWhere(req.principal, { - tableAlias: "t", - orgColumn: "t.org_id", - projectColumn: "t.project_id", - permCode: PERM.transmittal.read, - preferProject: true, - }); - - const extra = buildExtraFilters({ project_id, org_id, tr_no, q }); - const where = - [base.where, extra.where].filter(Boolean).join(" AND ") || "1=1"; - const params = { ...base.params, ...extra.params, limit, offset }; - - // total - const [[{ cnt: total }]] = await sql.query( - `SELECT COUNT(*) AS cnt FROM transmittals t WHERE ${where}`, - params - ); - - // rows - const [rows] = await sql.query( - `SELECT t.* - FROM transmittals t - WHERE ${where} - ORDER BY ${orderBy} - LIMIT :limit OFFSET :offset`, - params - ); - - res.json({ - data: rows, - total: Number(total || 0), - page: p, - pageSize: ps, - }); - } catch (e) { - res.status(500).json({ error: e.message || "transmittals/list failed" }); + if (!P.is_superadmin) { + if (project_id) { + const pid = Number(project_id); + if (!P.inProject(pid)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + cond.push("t.project_id=?"); + params.push(pid); + } else if (P.project_ids?.length) { + cond.push( + `t.project_id IN (${P.project_ids.map(() => "?").join(",")})` + ); + params.push(...P.project_ids); + } + } else if (project_id) { + cond.push("t.project_id=?"); + params.push(Number(project_id)); } + + if (tr_no) { + cond.push("t.tr_no=?"); + params.push(tr_no); + } + if (q) { + cond.push("(t.tr_no LIKE ? OR t.subject LIKE ?)"); + params.push(`%${q}%`, `%${q}%`); + } + + const where = cond.length ? `WHERE ${cond.join(" AND ")}` : ""; + const [rows] = await sql.query( + `SELECT t.* FROM transmittals t ${where} ORDER BY t.id DESC LIMIT ? OFFSET ?`, + [...params, Number(limit), Number(offset)] + ); + res.json(rows); } ); -/* ------------------------------- GET ONE ------------------------------ */ -r.get( - "/:id", - requirePerm(PERM.transmittal.read, { - scope: "org", - getOrgId: OWN.getOrgIdById, - }), - async (req, res) => { - try { - const id = Number(req.params.id); - const [[row]] = await sql.query("SELECT * FROM transmittals WHERE id=?", [ - id, - ]); - if (!row) return res.status(404).json({ error: "Not found" }); - res.json(row); - } catch (e) { - res - .status(500) - .json({ error: e.message || "transmittals/detail failed" }); - } - } -); +// GET +r.get("/:id", requirePerm("transmittals.manage"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM transmittals WHERE id=?", [ + id, + ]); + if (!row) return res.status(404).json({ error: "Not found" }); + const P = req.principal; + if (!P.is_superadmin && !P.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + res.json(row); +}); -/* -------------------------------- CREATE ------------------------------ */ +// CREATE r.post( "/", - requirePerm(PERM.transmittal.create, { - scope: "org", - getOrgId: async (req) => req.body?.org_id ?? null, - }), + requirePerm("transmittals.manage", { projectParam: "project_id" }), async (req, res) => { - try { - // ยึดสคีมาหลักจาก transmittals.js - const { org_id, project_id, tr_no, subject, status } = req.body; - const [rs] = await sql.query( - `INSERT INTO transmittals (org_id, project_id, tr_no, subject, status, created_by) - VALUES (?,?,?,?,?,?)`, - [org_id, project_id, tr_no, subject, status, req.principal.userId] - ); - res.status(201).json({ id: rs.insertId }); - } catch (e) { - res - .status(500) - .json({ error: e.message || "transmittals/create failed" }); - } + const { org_id, project_id, tr_no, subject, status } = req.body || {}; + if (!project_id || !tr_no) + return res.status(400).json({ error: "project_id and tr_no required" }); + const [rs] = await sql.query( + `INSERT INTO transmittals (org_id, project_id, tr_no, subject, status, created_by) + VALUES (?,?,?,?,?,?)`, + [ + org_id ?? null, + project_id, + tr_no, + subject ?? null, + status ?? null, + req.principal.user_id, + ] + ); + res.status(201).json({ id: rs.insertId }); } ); -/* -------------------------------- UPDATE ------------------------------ */ -// PUT: รูปแบบเดิม (อัปเดต subject, status) -r.put( - "/:id", - requirePerm(PERM.transmittal.update, { - scope: "org", - getOrgId: OWN.getOrgIdById, - }), - async (req, res) => { - try { - const id = Number(req.params.id); - const { subject, status } = req.body; - await sql.query( - "UPDATE transmittals SET subject=?, status=? WHERE id=?", - [subject, status, id] - ); - res.json({ ok: 1 }); - } catch (e) { - res - .status(500) - .json({ error: e.message || "transmittals/update failed" }); - } - } -); +// UPDATE +r.patch("/:id", requirePerm("transmittals.manage"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM transmittals WHERE id=?", [ + id, + ]); + if (!row) return res.status(404).json({ error: "Not found" }); + const P = req.principal; + if (!P.is_superadmin && !P.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); -// PATCH: อัปเดตบางฟิลด์ (ไม่บังคับคอลัมน์ใหม่ใน DB — จะอัปเดตก็ต่อเมื่อ body ส่งมา) -r.patch( - "/:id", - requirePerm(PERM.transmittal.update, { - scope: "org", - getOrgId: OWN.getOrgIdById, - }), - async (req, res) => { - try { - const id = Number(req.params.id); - // อนุญาตเฉพาะฟิลด์ที่คาดว่ามีในสคีมาหลัก - const allowed = ["tr_no", "subject", "status"]; + const allowed = ["tr_no", "subject", "status"]; + const patch = {}; + for (const k of allowed) if (k in req.body) patch[k] = req.body[k]; + if (!Object.keys(patch).length) + return res.status(400).json({ error: "no fields to update" }); - // ถ้าฐานข้อมูลของคุณมีคอลัมน์เพิ่ม เช่น to_party, sent_date, description - // และต้องการอัปเดตด้วย ให้เพิ่มชื่อคอลัมน์เข้าไปใน allowed ได้ - // const allowed = ['tr_no','subject','status','to_party','sent_date','description']; + const sets = Object.keys(patch).map((k) => `\`${k}\`=?`); + await sql.query(`UPDATE transmittals SET ${sets.join(", ")} WHERE id=?`, [ + ...Object.values(patch), + id, + ]); + res.json({ ok: 1 }); +}); - const patch = {}; - for (const k of allowed) if (k in req.body) patch[k] = req.body[k]; - - if (Object.keys(patch).length === 0) { - return res.status(400).json({ error: "no fields to update" }); - } - - if ("status" in patch) { - const s = String(patch.status); - const ok = [ - "draft", - "submitted", - "Sent", - "Closed", - "Approved", - "Pending", - "Review", - ].includes(s); - if (!ok) return res.status(400).json({ error: "invalid status" }); - } - - const sets = Object.keys(patch).map((k) => `\`${k}\` = :${k}`); - patch.id = id; - - await sql.query( - `UPDATE transmittals SET ${sets.join( - ", " - )}, updated_at = NOW() WHERE id = :id`, - patch - ); - res.json({ ok: 1, id }); - } catch (e) { - res.status(500).json({ error: e.message || "transmittals/patch failed" }); - } - } -); - -/* -------------------------------- DELETE ------------------------------ */ -r.delete( - "/:id", - requirePerm(PERM.transmittal.delete, { - scope: "org", - getOrgId: OWN.getOrgIdById, - }), - async (req, res) => { - try { - const id = Number(req.params.id); - await sql.query("DELETE FROM transmittals WHERE id=?", [id]); - res.json({ ok: 1 }); - } catch (e) { - res - .status(500) - .json({ error: e.message || "transmittals/delete failed" }); - } - } -); +// DELETE +r.delete("/:id", requirePerm("transmittals.manage"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM transmittals WHERE id=?", [ + id, + ]); + if (!row) return res.status(404).json({ error: "Not found" }); + const P = req.principal; + if (!P.is_superadmin && !P.inProject(row.project_id)) + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + await sql.query("DELETE FROM transmittals WHERE id=?", [id]); + res.json({ ok: 1 }); +}); export default r; diff --git a/backend/src/routes/uploads.js b/backend/src/routes/uploads.js index bec29997..ab7b5621 100644 --- a/backend/src/routes/uploads.js +++ b/backend/src/routes/uploads.js @@ -1,69 +1,46 @@ -// FILE: src/routes/uploads.js -// 03.2 10) เพิ่ม routes/uploads.js (ใหม่) -// - ใช้ร่วมกับ requirePerm() -// - สำหรับอัพโหลดไฟล์แนบที่เกี่ยวข้องกับ module item ต่างๆ (เช่น correspondence, rfa, drawing) ตามสิทธิ์ของผู้ใช้ -// Uploads routes -// - POST /:module/:id/file to upload a file associated with a module item (e.g. correspondence, rfa, drawing) -// - Uses multer for file handling -// - Stores files in structured directories based on org_id, project_id, and creation date -// - Requires appropriate permissions via requirePerm middleware -// - Supported modules: correspondences -// - Requires appropriate permissions via requirePerm middleware -// - Permissions are mapped in PERM_UPLOAD -// - Ensure req.user.permissions is populated (e.g. via auth.js or authJwt.js with enrichment) -// - Requires req.user to have the upload permission for the specific module and project scope -// - Example: POST /correspondences/123/file with form-data including 'file' field -// - Environment variable UPLOAD_BASE defines the base directory for uploads (default: /share/dms-data) -// - Directory structure: UPLOAD_BASE/module/org_id/project_id/YYYY-MM -// - Filename format: timestamp__originalname (with unsafe characters replaced by '_') -// - Response: { ok: 1, module, ref_id, filename, path, size, mime } -// - Assumes existence of necessary database tables and columns -// - Assumes existence of necessary middleware and utility functions -// - Assumes Express.js app is set up to use this router for /api/uploads path -// - Assumes existence of necessary environment variables -// - Assumes existence of necessary directories and permissions for file storage -// - Assumes multer is installed and configured -// - Assumes fs and path modules are available for file system operations -// - Assumes sql module is set up for database interactions -// - Assumes PERM constants are defined in config/permissions.js -// - Assumes requirePerm middleware is defined in middleware/requirePerm.js -// - Assumes Express.js app is set up to use this router for /api/uploads path -// - Assumes multer is installed and configured -// - Assumes fs and path modules are available for file system operations -// - Assumes sql module is set up for database interactions - +// FILE: backend/src/routes/uploads.js +// อัปโหลดไฟล์ผูกกับโมดูล (PROJECT scope): documents/drawings/correspondences/rfas/transmittals import { Router } from "express"; import multer from "multer"; import fs from "node:fs"; import path from "node:path"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import PERM from "../config/permissions.js"; const r = Router(); const UPLOAD_BASE = process.env.UPLOAD_BASE || "/share/dms-data"; -function ensureDir(p) { +const ensureDir = (p) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); -} +}; +async function fetchRef(module, id) { + const tbl = String(module); + const idCol = "id"; + const [[row]] = await sql.query( + `SELECT org_id, project_id, created_at FROM ${tbl} WHERE ${idCol}=?`, + [Number(id)] + ); + return row || null; +} +function sanitize(name) { + // แทนที่อักขระไม่ปลอดภัย + return String(name).replace(/[^A-Za-z0-9._-]+/g, "_"); +} const storage = multer.diskStorage({ - destination: async (req, file, cb) => { + destination: async (req, _file, cb) => { try { const { module, id } = req.params; - const [[row]] = await sql.query( - `SELECT org_id, project_id, created_at FROM ${module} WHERE id=?`, - [Number(id)] - ); - if (!row) return cb(new Error("Resource not found")); - const dt = new Date(row.created_at || Date.now()); + const ref = await fetchRef(module, id); + if (!ref) return cb(new Error("Resource not found")); + const dt = new Date(ref.created_at || Date.now()); const ym = `${dt.getUTCFullYear()}-${String( dt.getUTCMonth() + 1 ).padStart(2, "0")}`; const dir = path.join( UPLOAD_BASE, module, - String(row.org_id), - String(row.project_id), + String(ref.org_id || "0"), + String(ref.project_id || "0"), ym ); ensureDir(dir); @@ -72,23 +49,21 @@ const storage = multer.diskStorage({ cb(e); } }, - filename: (req, file, cb) => { - const ts = Date.now(); - const safe = file.originalname.replace(/[\^\w.\-]+/g, "_"); - cb(null, `${ts}__${safe}`); - }, + filename: (_req, file, cb) => + cb(null, `${Date.now()}__${sanitize(file.originalname)}`), }); const upload = multer({ storage }); -const PERM_UPLOAD = { - correspondences: PERM.correspondence.upload, - rfas: PERM.rfa.upload, - drawings: PERM.drawing.upload, - transmittals: PERM.transmittal?.upload, -}; - -async function getProjectIdByModule(req) { - const { module, id } = req.params; +// map module -> permission +function uploadPerm(module) { + if (module === "documents") return "documents.manage"; + if (module === "drawings") return "drawings.upload"; + if (module === "correspondences") return "corr.manage"; + if (module === "rfas") return "rfas.respond"; + if (module === "transmittals") return "transmittals.manage"; + return null; +} +async function refProjectId(module, id) { const [[row]] = await sql.query( `SELECT project_id FROM ${module} WHERE id=?`, [Number(id)] @@ -99,12 +74,20 @@ async function getProjectIdByModule(req) { r.post( "/:module/:id/file", (req, res, next) => { - const perm = PERM_UPLOAD[req.params.module]; + const perm = uploadPerm(req.params.module); if (!perm) return res.status(400).json({ error: "Unsupported module" }); - return requirePerm(perm, { - scope: "project", - getProjectId: getProjectIdByModule, - })(req, res, next); + return requirePerm(perm, { projectParam: undefined })(req, res, next); + }, + async (req, res, next) => { + // ABAC: ตรวจ project scope ของ record + const pid = await refProjectId(req.params.module, req.params.id); + if ( + !req.principal.is_superadmin && + (!pid || !req.principal.inProject(pid)) + ) { + return res.status(403).json({ error: "FORBIDDEN_PROJECT" }); + } + next(); }, upload.single("file"), async (req, res) => { diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 2d04e749..9f96432c 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -1,51 +1,55 @@ -// FILE: src/routes/users.js -// 03.2 11) เพิ่ม routes/users.js (ใหม่) -// - ใช้ร่วมกับ requirePerm() -// - สำหรับดูข้อมูลผู้ใช้ตัวเอง และรายชื่อผู้ใช้ (สำหรับ SUPER_ADMIN หรือ ADMIN เท่านั้น) -// Users routes -// - GET /me to get current user info and roles -// - GET /api/users to list users (for SUPER_ADMIN or ADMIN only) -// - Requires appropriate permissions via requirePerm middleware -// - Uses req.principal loaded by loadPrincipal middleware -// (make sure to use loadPrincipalMw() in app.js or the parent router) -// (e.g. app.use('/api', requireAuth(), enrichPermissions(), loadPrincipalMw(), apiRouter);) -// - req.principal has { userId, roleIds, roleCodes, permissions } -// (see utils/rbac.js for details) -// - Uses Sequelize ORM for DB access - +// FILE: backend/src/routes/users.js import { Router } from "express"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import PERM from "../config/permissions.js"; const r = Router(); -// ME +// 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 FROM users WHERE user_id=?", - [req.principal.userId] + `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" }); - - // roles in plain const [roles] = await sql.query( - ` - SELECT r.role_code, r.role_name, ur.org_id, ur.project_id - FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id - WHERE ur.user_id=?`, - [req.principal.userId] + `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: [...req.principal.roleCodes] }); + res.json({ + ...u, + roles, + role_codes: roles.map((r) => r.role_code), + permissions: [...(p.permissions || [])], + project_ids: p.project_ids, + org_ids: p.org_ids, + is_superadmin: p.is_superadmin, + }); }); -// (optional) USERS LIST – ให้เฉพาะ SUPER_ADMIN หรือ ADMIN (ใน org ตัวเอง) -r.get("/", requirePerm("user.read", { scope: "global" }), async (req, res) => { - const [rows] = await sql.query( - "SELECT user_id, username, email FROM users LIMIT 200" - ); - res.json(rows); -}); +// 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; diff --git a/backend/src/routes/users_extras.js b/backend/src/routes/users_extras.js index 55d7a128..e252772c 100644 --- a/backend/src/routes/users_extras.js +++ b/backend/src/routes/users_extras.js @@ -1,93 +1,39 @@ -// FILE: src/routes/users_extras.js -// Users extra routes -// - PATCH /users/:id/password to change user password (self or admin) -// - GET /users/search for user search (admin only) -// - GET /users/me/projects to list user's projects and roles -// - Requires authentication and appropriate permissions/roles -// via requireAuth and requireRole middleware -// - Uses Sequelize ORM for DB access -// - Passwords are hashed using bcrypt -// - UserProjectRole and Project models are used for project-role listing -// - Assumes User model is defined in Sequelize setup -// - Assumes hashPassword utility function is defined for password hashing -// - Assumes requireAuth and requireRole middleware are defined for auth -// - Assumes sequelize instance is set up and connected to DB -// - Assumes UserProjectRole and Project Sequelize models are defined -// - Assumes User Sequelize model is defined -// - Assumes hashPassword function is defined in utils/passwords.js -// - Assumes requireAuth middleware is defined in middleware/auth.js -// - Assumes requireRole middleware is defined in middleware/rbac.js -// - Assumes sequelize instance is imported from db/sequelize.js -// - Assumes UserProjectRole and Project models are imported from db/models/UserProjectRole.js and db/models/Project.js respectively - +// FILE: backend/src/routes/users_extras.js +// NOTE: ของเดิมใช้ cookie + Sequelize -> ปรับให้อยู่หลัง Bearer stack และจำกัดความสามารถ import { Router } from "express"; -import { requireAuth } from "../middleware/auth.js"; -import { requireRole } from "../middleware/rbac.js"; -import { User } from "../db/sequelize.js"; -import { hashPassword } from "../utils/passwords.js"; -import { sequelize } from "../db/sequelize.js"; -import UPRModel from "../db/models/UserProjectRole.js"; -import ProjectModel from "../db/models/Project.js"; +import sql from "../db/index.js"; +import { requirePerm } from "../middleware/requirePerm.js"; const r = Router(); -const UPR = UPRModel(sequelize); -const Project = ProjectModel(sequelize); -// self or admin change password -r.patch("/users/:id/password", requireAuth, async (req, res) => { - const targetId = Number(req.params.id); - const isSelf = req.user?.user_id === targetId; - const isAdmin = (req.user?.roles || []).includes("Admin"); - if (!isSelf && !isAdmin) return res.status(403).json({ error: "Forbidden" }); +/** + * PATCH /users/:id/password + * เฉพาะผู้มี settings.manage (GLOBAL) — (คำเตือน: ต้องมีระบบ hash/rotate ที่ service auth) + */ +r.patch( + "/users/:id/password", + requirePerm("settings.manage"), + async (_req, res) => { + // ในโปรเจคนี้การเปลี่ยนรหัสผ่านควรวิ่งที่ auth service/procedure โดยเฉพาะ + return res + .status(501) + .json({ error: "Not implemented here. Use auth service." }); + } +); - const { new_password } = req.body || {}; - if (!new_password) - return res.status(400).json({ error: "new_password required" }); - - const row = await User.findByPk(targetId); - if (!row) return res.status(404).json({ error: "Not found" }); - - row.password_hash = await hashPassword(new_password); - await row.save(); - res.json({ ok: true }); -}); - -// user search (autocomplete) -r.get("/users/search", requireAuth, requireRole("Admin"), async (req, res) => { - const q = String(req.query.q || "").toLowerCase(); - const where = q - ? { - username: sequelize.where( - sequelize.fn("LOWER", sequelize.col("username")), - "LIKE", - `%${q}%` - ), - } - : {}; - const rows = await User.findAll({ - where, - limit: 20, - order: [["username", "ASC"]], - attributes: ["user_id", "username", "first_name", "last_name", "email"], - }); +/** + * GET /users/me/projects — สรุปโปรเจ็ค/บทบาทของผู้ใช้ + */ +r.get("/users/me/projects", async (req, res) => { + const uid = req.principal.user_id; + const [rows] = await sql.query( + `SELECT upr.project_id, r.role_code, r.role_name + FROM user_project_roles upr + JOIN roles r ON r.role_id = upr.role_id + WHERE upr.user_id=? ORDER BY upr.project_id`, + [uid] + ); res.json(rows); }); -// my projects/roles -r.get("/users/me/projects", requireAuth, async (req, res) => { - const user_id = req.user?.user_id; - if (!user_id) return res.status(401).json({ error: "Unauthorized" }); - const rows = await UPR.findAll({ where: { user_id } }); - // Optionally join project names - const projectIds = [...new Set(rows.map((r) => r.project_id))]; - const projects = await Project.findAll({ where: { project_id: projectIds } }); - const map = new Map(projects.map((p) => [p.project_id, p.project_name])); - const result = rows.map((r) => ({ - project_id: r.project_id, - role_name: r.role_name, - project_name: map.get(r.project_id) || null, - })); - res.json(result); -}); - export default r; diff --git a/backend/src/routes/view.js b/backend/src/routes/view.js index d8d56d6f..c721c3bc 100644 --- a/backend/src/routes/view.js +++ b/backend/src/routes/view.js @@ -1,178 +1,100 @@ -// FILE: src/routes/view.js -// Saved Views routes -// - CRUD operations for saved views -// - Requires appropriate permissions via requirePerm middleware -// - Supports filtering and pagination on list endpoint -// - Uses ownerResolvers utility to determine org ownership for permission checks -// - Permissions required are defined in config/permissions.js -// - savedview.read -// - savedview.create -// - savedview.update -// - savedview.delete -// - Scope can be 'global' (list), 'org' (get/create/update/delete) -// - List endpoint supports filtering by project_id, org_id, shared flag, and search query (q) -// - Pagination via limit and offset query parameters -// - Results ordered by id DESC -// - Error handling for not found and no fields to update scenarios -// - Uses async/await for asynchronous operations -// - SQL queries use parameterized queries to prevent SQL injection -// - Responses are in JSON format -// - Middleware functions are used for permission checks -// - Owner resolvers are used to fetch org_id for specific view ids -// - Code is modular and organized for maintainability -// - Comments are provided for clarity/documentation -// - Follows best practices for Express.js route handling -// - Uses ES6+ features for cleaner code -// - Assumes existence of saved_views table with appropriate columns -// - Assumes existence of users table for owner -// - Assumes existence of config/permissions.js with defined permission codes -// - Assumes existence of utils/scope.js with buildScopeWhere and ownerResolvers functions -// - Assumes existence of middleware/requirePerm.js for permission checks -// - Assumes existence of db/index.js for database connection/querying -// - Assumes Express.js app is set up to use this router for /api/saved_views path - +// FILE: backend/src/routes/view.js +// Saved Views: อ่านด้วย reports.view (GLOBAL); เขียนด้วย settings.manage import { Router } from "express"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import { buildScopeWhere, ownerResolvers } from "../utils/scope.js"; -import PERM from "../config/permissions.js"; const r = Router(); -const OWN = ownerResolvers(sql, "saved_views", "id"); -// LIST: GET /api/view?project_id=&org_id=&shared=1 -r.get( - "/", - requirePerm(PERM.savedview.read, { scope: "global" }), - async (req, res) => { - const { project_id, org_id, shared, q, limit = 50, offset = 0 } = req.query; - - const base = buildScopeWhere(req.principal, { - tableAlias: "v", - orgColumn: "v.org_id", - projectColumn: "v.project_id", - permCode: PERM.savedview.read, - preferProject: true, - }); - - const extra = []; - const params = { - ...base.params, - limit: Number(limit), - offset: Number(offset), - my: req.principal.userId, - }; - if (project_id) { - extra.push("v.project_id = :project_id"); - params.project_id = Number(project_id); - } - if (org_id) { - extra.push("v.org_id = :org_id"); - params.org_id = Number(org_id); - } - if (shared === "1") extra.push("v.is_shared = 1"); - if (q) { - extra.push("(v.name LIKE :q)"); - params.q = `%${q}%`; - } - - // ให้ผู้ใช้เห็นของตัวเองเสมอ + ของที่อยู่ใน scope - const where = `(${base.where}) AND (v.is_shared=1 OR v.owner_user_id=:my${ - extra.length ? " OR " + extra.join(" AND ") : "" - })`; - - const [rows] = await sql.query( - `SELECT v.* FROM saved_views v - WHERE ${where} - ORDER BY v.id DESC - LIMIT :limit OFFSET :offset`, - params - ); - res.json(rows); +// LIST (ทุกคนที่มี reports.view) +r.get("/", requirePerm("reports.view"), async (req, res) => { + const { project_id, shared = "1", q, limit = 50, offset = 0 } = req.query; + const p = req.principal; + const cond = []; + const params = []; + // ให้เห็นของตัวเองเสมอ + shared + cond.push("(v.is_shared=1 OR v.owner_user_id=?)"); + params.push(p.user_id); + if (project_id) { + cond.push("v.project_id=?"); + params.push(Number(project_id)); + } + if (q) { + cond.push("v.name LIKE ?"); + params.push(`%${q}%`); + } + if (shared === "0") { + cond.push("v.is_shared=0"); } -); -// GET by id -r.get( - "/:id", - requirePerm(PERM.savedview.read, { - scope: "org", - getOrgId: OWN.getOrgIdById, - }), - async (req, res) => { - const id = Number(req.params.id); - const [[row]] = await sql.query("SELECT * FROM saved_views WHERE id=?", [ + const where = `WHERE ${cond.join(" AND ")}`; + const [rows] = await sql.query( + `SELECT v.* FROM saved_views v ${where} ORDER BY v.id DESC LIMIT ? OFFSET ?`, + [...params, Number(limit), Number(offset)] + ); + res.json(rows); +}); + +// GET +r.get("/:id", requirePerm("reports.view"), async (req, res) => { + const id = Number(req.params.id); + const [[row]] = await sql.query("SELECT * FROM saved_views WHERE id=?", [id]); + if (!row) return res.status(404).json({ error: "Not found" }); + if ( + !( + row.is_shared || + row.owner_user_id === req.principal.user_id || + req.principal.is_superadmin + ) + ) { + return res.status(403).json({ error: "FORBIDDEN" }); + } + res.json(row); +}); + +// CREATE / UPDATE / DELETE (ต้องมี settings.manage) +r.post("/", requirePerm("settings.manage"), async (req, res) => { + const { + org_id, + project_id, + name, + payload_json, + is_shared = 0, + } = req.body || {}; + const [rs] = await sql.query( + `INSERT INTO saved_views (org_id, project_id, name, payload_json, is_shared, owner_user_id) + VALUES (?,?,?,?,?,?)`, + [ + org_id ?? null, + project_id ?? null, + name ?? "", + JSON.stringify(payload_json ?? {}), + Number(is_shared) ? 1 : 0, + req.principal.user_id, + ] + ); + res.status(201).json({ id: rs.insertId }); +}); + +r.put("/:id", requirePerm("settings.manage"), async (req, res) => { + const id = Number(req.params.id); + const { name, payload_json, is_shared } = req.body || {}; + await sql.query( + "UPDATE saved_views SET name=?, payload_json=?, is_shared=? WHERE id=?", + [ + name ?? null, + JSON.stringify(payload_json ?? {}), + Number(is_shared) ? 1 : 0, id, - ]); - if (!row) return res.status(404).json({ error: "Not found" }); - res.json(row); - } -); + ] + ); + res.json({ ok: 1 }); +}); -// CREATE -r.post( - "/", - requirePerm(PERM.savedview.create, { - scope: "org", - getOrgId: async (req) => req.body?.org_id ?? null, - }), - async (req, res) => { - const { org_id, project_id, name, payload_json, is_shared = 0 } = req.body; - const [rs] = await sql.query( - `INSERT INTO saved_views (org_id, project_id, name, payload_json, is_shared, owner_user_id) - VALUES (?,?,?,?,?,?)`, - [ - org_id, - project_id, - name, - JSON.stringify(payload_json ?? {}), - Number(is_shared) ? 1 : 0, - req.principal.userId, - ] - ); - res.json({ id: rs.insertId }); - } -); - -// UPDATE (เฉพาะใน scope และถ้าเป็นของตนเอง หรือเป็นแอดมินตามนโยบาย) -r.put( - "/:id", - requirePerm(PERM.savedview.update, { - scope: "org", - getOrgId: OWN.getOrgIdById, - }), - async (req, res) => { - const id = Number(req.params.id); - const { name, payload_json, is_shared } = req.body; - - // ตรวจ owner ถ้าต้องการบังคับเป็นของตัวเอง (option) - const [[sv]] = await sql.query( - "SELECT owner_user_id FROM saved_views WHERE id=?", - [id] - ); - if (!sv) return res.status(404).json({ error: "Not found" }); - // ถ้าจะจำกัดเฉพาะเจ้าของ: if (sv.owner_user_id !== req.principal.userId && !req.principal.isSuperAdmin) return res.status(403).json({ error: 'NOT_OWNER' }); - - await sql.query( - "UPDATE saved_views SET name=?, payload_json=?, is_shared=? WHERE id=?", - [name, JSON.stringify(payload_json ?? {}), Number(is_shared) ? 1 : 0, id] - ); - res.json({ ok: 1 }); - } -); - -// DELETE -r.delete( - "/:id", - requirePerm(PERM.savedview.delete, { - scope: "org", - getOrgId: OWN.getOrgIdById, - }), - async (req, res) => { - const id = Number(req.params.id); - await sql.query("DELETE FROM saved_views WHERE id=?", [id]); - res.json({ ok: 1 }); - } -); +r.delete("/:id", requirePerm("settings.manage"), async (req, res) => { + const id = Number(req.params.id); + await sql.query("DELETE FROM saved_views WHERE id=?", [id]); + res.json({ ok: 1 }); +}); export default r; diff --git a/backend/src/routes/views.js b/backend/src/routes/views.js index 5725f78b..e1c31870 100644 --- a/backend/src/routes/views.js +++ b/backend/src/routes/views.js @@ -1,47 +1,32 @@ -// FILE: src/routes/views.js -// Views routes -// - GET /api/views to list all views -// - GET /api/views/:view_name to get view definition -// - Requires appropriate permissions via requirePerm middleware - +// FILE: backend/src/routes/views.js +// จำกัดเฉพาะแอดมินระบบ: settings.manage import { Router } from "express"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import PERM from "../config/permissions.js"; const r = Router(); const DB_NAME = process.env.DB_NAME || "dms_db"; -// LIST views -r.get( - "/", - requirePerm(PERM.viewdef.read, { scope: "global" }), - async (req, res) => { - const [rows] = await sql.query( - `SELECT TABLE_SCHEMA AS db, TABLE_NAME AS view_name +r.get("/", requirePerm("settings.manage"), async (_req, res) => { + const [rows] = await sql.query( + `SELECT TABLE_SCHEMA AS db, TABLE_NAME AS view_name FROM information_schema.VIEWS - WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME`, - [DB_NAME] - ); - res.json(rows); - } -); + WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME`, + [DB_NAME] + ); + res.json(rows); +}); -// GET view definition -r.get( - "/:view_name", - requirePerm(PERM.viewdef.read, { scope: "global" }), - async (req, res) => { - const viewName = req.params.view_name; - const [[row]] = await sql.query( - `SELECT VIEW_DEFINITION AS definition +r.get("/:view_name", requirePerm("settings.manage"), async (req, res) => { + const viewName = req.params.view_name; + const [[row]] = await sql.query( + `SELECT VIEW_DEFINITION AS definition FROM information_schema.VIEWS - WHERE TABLE_SCHEMA=? AND TABLE_NAME=?`, - [DB_NAME, viewName] - ); - if (!row) return res.status(404).json({ error: "Not found" }); - res.json({ view: viewName, definition: row.definition }); - } -); + WHERE TABLE_SCHEMA=? AND TABLE_NAME=?`, + [DB_NAME, viewName] + ); + if (!row) return res.status(404).json({ error: "Not found" }); + res.json({ view: viewName, definition: row.definition }); +}); export default r; diff --git a/backend/src/routes/volumes.js b/backend/src/routes/volumes.js index a933162b..0ffc6cbf 100644 --- a/backend/src/routes/volumes.js +++ b/backend/src/routes/volumes.js @@ -1,70 +1,55 @@ -// FILE: src/routes/volumes.js -// Volumes routes -// - CRUD operations for volumes -// - Requires appropriate permissions via requirePerm middleware -// - Uses global scope for all permissions -// - volume:read, volume:create, volume:update, volume:delete -// - Volume fields: volume_id (PK), volume_code, volume_name -// - volume_code is unique -// - Basic validation: volume_code and volume_name required for create +// FILE: backend/src/routes/volumes.js +// Master data: volumes +// - Read: organizations.view (GLOBAL) +// - Write: settings.manage (GLOBAL) import { Router } from "express"; import sql from "../db/index.js"; import { requirePerm } from "../middleware/requirePerm.js"; -import PERM from "../config/permissions.js"; const r = Router(); -// LIST: GET /api/volumes -r.get( - "/", - requirePerm(PERM.volume.read, { scope: "global" }), - async (req, res) => { - const [rows] = await sql.query( - "SELECT * FROM volumes ORDER BY volume_id DESC" - ); - res.json(rows); - } -); +// LIST +r.get("/", requirePerm("organizations.view"), async (_req, res) => { + const [rows] = await sql.query( + "SELECT volume_id, volume_code, volume_name FROM volumes ORDER BY volume_code ASC" + ); + res.json(rows); +}); // CREATE -r.post( - "/", - requirePerm(PERM.volume.create, { scope: "global" }), - async (req, res) => { - const { volume_code, volume_name } = req.body; - const [rs] = await sql.query( - "INSERT INTO volumes (volume_code, volume_name) VALUES (?,?)", - [volume_code, volume_name] - ); - res.json({ volume_id: rs.insertId }); +r.post("/", requirePerm("settings.manage"), async (req, res) => { + const { volume_code, volume_name } = req.body || {}; + if (!volume_code || !volume_name) { + return res + .status(400) + .json({ error: "volume_code and volume_name required" }); } -); + const [rs] = await sql.query( + "INSERT INTO volumes (volume_code, volume_name) VALUES (?, ?)", + [volume_code, volume_name] + ); + res.status(201).json({ volume_id: rs.insertId }); +}); // UPDATE -r.put( - "/:id", - requirePerm(PERM.volume.update, { scope: "global" }), - async (req, res) => { - const id = Number(req.params.id); - const { volume_name } = req.body; - await sql.query("UPDATE volumes SET volume_name=? WHERE volume_id=?", [ - volume_name, - id, - ]); - res.json({ ok: 1 }); - } -); +r.put("/:id", requirePerm("settings.manage"), async (req, res) => { + const id = Number(req.params.id); + const { volume_name } = req.body || {}; + if (!volume_name) + return res.status(400).json({ error: "volume_name required" }); + await sql.query("UPDATE volumes SET volume_name=? WHERE volume_id=?", [ + volume_name, + id, + ]); + res.json({ ok: true }); +}); // DELETE -r.delete( - "/:id", - requirePerm(PERM.volume.delete, { scope: "global" }), - async (req, res) => { - const id = Number(req.params.id); - await sql.query("DELETE FROM volumes WHERE volume_id=?", [id]); - res.json({ ok: 1 }); - } -); +r.delete("/:id", requirePerm("settings.manage"), async (req, res) => { + const id = Number(req.params.id); + await sql.query("DELETE FROM volumes WHERE volume_id=?", [id]); + res.json({ ok: true }); +}); export default r; diff --git a/backend/src/utils/passwords.js b/backend/src/utils/passwords.js index 012b4127..bdc46e20 100644 --- a/backend/src/utils/passwords.js +++ b/backend/src/utils/passwords.js @@ -1,4 +1,4 @@ -// FILE: src/utils/passwords.js +// FILE: backend/src/utils/passwords.js // Password hashing and verification utilities // - Uses bcrypt for secure password hashing // - Provides hashPassword(plain) and verifyPassword(plain, hash) functions diff --git a/backend/src/utils/rbac.js b/backend/src/utils/rbac.js index 80595a72..5ec56aba 100644 --- a/backend/src/utils/rbac.js +++ b/backend/src/utils/rbac.js @@ -1,4 +1,4 @@ -// FILE: src/utils/rbac.js +// FILE: backend/src/utils/rbac.js // 03.2 2) เพิ่มตัวช่วย RBAC (ใหม่) // Role-Based Access Control (RBAC) utilities // - loadPrincipal(userId) to load user's roles, permissions, orgs, projects diff --git a/backend/src/utils/scope.js b/backend/src/utils/scope.js index 49d6ca35..b7ea9c7d 100644 --- a/backend/src/utils/scope.js +++ b/backend/src/utils/scope.js @@ -1,4 +1,4 @@ -// FILE: src/utils/scope.js +// FILE: backend/src/utils/scope.js // 03.2 5) เพิ่ม utils/scope.js (ใหม่) // - ใช้ร่วมกับ requirePerm() และ loadPrincipal() // - สำหรับสร้าง SQL WHERE clause ในการ list entities ตาม scope ของผู้ใช้ diff --git a/mariadb/init/01_dms_v0_5_0_data_v5_1_deploy_table_rbac.sql b/mariadb/init/01_dms_v0_5_0_data_v5_1_deploy_table_rbac.sql index 67e4765b..3c7b8d50 100644 --- a/mariadb/init/01_dms_v0_5_0_data_v5_1_deploy_table_rbac.sql +++ b/mariadb/init/01_dms_v0_5_0_data_v5_1_deploy_table_rbac.sql @@ -36,6 +36,7 @@ DROP TABLE IF EXISTS memorandum_details; DROP TABLE IF EXISTS minutes_of_meeting_details; DROP TABLE IF EXISTS rfi_details; DROP TABLE IF EXISTS rfa_items; +DROP TABLE IF EXISTS rfas; DROP TABLE IF EXISTS technicaldocs; DROP TABLE IF EXISTS approve_codes; DROP TABLE IF EXISTS document_status_codes; @@ -422,6 +423,27 @@ CREATE TABLE rfa_items ( CREATE INDEX idx_rfaitems_rfa ON rfa_items(rfa_corr_id); CREATE INDEX idx_rfaitems_techdoc ON rfa_items(technical_doc_id); +CREATE TABLE rfas ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(64) NULL, + title VARCHAR(255) NOT NULL, + discipline VARCHAR(64) NULL, + due_date DATE NULL, + description TEXT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'draft', + owner_id INT UNSIGNED NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_rfas_code (code), + KEY idx_rfas_status_updated (status, updated_at), + KEY idx_rfas_due_date (due_date), + KEY idx_rfas_updated_at (updated_at), + CONSTRAINT fk_rfas_owner + FOREIGN KEY (owner_id) REFERENCES users(id) + ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- ========================================================= -- CONTRACT DRAWINGS (normalized cat 1:M subcat; dwg → sub_cat) -- =========================================================