Update frontend login page.jsx และ backend

This commit is contained in:
admin
2025-09-29 13:25:09 +07:00
parent aca3667a9d
commit 7dd5ce8015
52 changed files with 2903 additions and 1289 deletions

View File

@@ -7,7 +7,6 @@
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"dev": "nodemon --watch src src/index.js",
"dev:desktop": "node --watch src/index.js",
@@ -16,7 +15,6 @@
"health": "node -e \"fetch('http://localhost:'+ (process.env.BACKEND_PORT||3001) +'/health').then(r=>r.text()).then(console.log).catch(e=>{console.error(e);process.exit(1)})\"",
"postinstall": "node -e \"console.log('Installed dms-backend %s','0.6.0')\""
},
"dependencies": {
"bcrypt": "5.1.1",
"bcryptjs": "^2.4.3",

View File

@@ -1,24 +1,39 @@
// 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)
// =============================================================
export const config = {
PORT: Number(process.env.BACKEND_PORT || 3001),
DB: {
HOST: process.env.DB_HOST || 'mariadb',
HOST: process.env.DB_HOST || "mariadb",
PORT: Number(process.env.DB_PORT || 3306),
USER: process.env.DB_USER || 'center',
PASS: process.env.DB_PASSWORD || 'Center#2025',
NAME: process.env.DB_NAME || 'dms',
USER: process.env.DB_USER || "center",
PASS: process.env.DB_PASSWORD || "Center#2025",
NAME: process.env.DB_NAME || "dms",
},
JWT: {
SECRET: process.env.JWT_SECRET || '8b0df02e4aee9f9f79a4f2d8ba77b0b82c1ee3446b68cb0bae94ab54d60f8d9e',
EXPIRES_IN: process.env.JWT_EXPIRES_IN || '8h',
REFRESH_SECRET: process.env.JWT_REFRESH_SECRET || '31r2Wp59E8JukXjYiMopzoHDxJBLVefjR9YOOHjk62R5PbFVXzbx2Wjyc9weVDgK',
REFRESH_EXPIRES_IN: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
SECRET:
process.env.JWT_SECRET ||
"8b0df02e4aee9f9f79a4f2d8ba77b0b82c1ee3446b68cb0bae94ab54d60f8d9e",
EXPIRES_IN: process.env.JWT_EXPIRES_IN || "8h",
REFRESH_SECRET:
process.env.JWT_REFRESH_SECRET ||
"31r2Wp59E8JukXjYiMopzoHDxJBLVefjR9YOOHjk62R5PbFVXzbx2Wjyc9weVDgK",
REFRESH_EXPIRES_IN: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
},
SECURITY: {
RATE_LIMIT_WINDOW_MS: Number(process.env.RATE_LIMIT_WINDOW_MS || 60_000),
RATE_LIMIT_MAX: Number(process.env.RATE_LIMIT_MAX || 100),
},
CORS_ORIGINS: (process.env.CORS_ALLOWLIST || '')
.split(',')
.map(s => s.trim())
CORS_ORIGINS: (process.env.CORS_ALLOWLIST || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
};

View File

@@ -1,45 +1,156 @@
// =============================================================
// 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.
// =============================================================
const V5_DOT = {
organization: { read: 'organization.read' },
project: { read: 'project.read', create: 'project.create', update: 'project.update', delete: 'project.delete' },
correspondence: { read: 'correspondence.read', create: 'correspondence.create', update: 'correspondence.update', delete: 'correspondence.delete', upload: 'correspondence.upload' },
rfa: { read: 'rfa.read', create: 'rfa.create', update: 'rfa.update', delete: 'rfa.delete', upload: 'rfa.upload' },
drawing: { read: 'drawing.read', create: 'drawing.create', update: 'drawing.update', delete: 'drawing.delete', upload: 'drawing.upload' },
transmittal: { read: 'transmittal.read', create: 'transmittal.create', update: 'transmittal.update', delete: 'transmittal.delete', upload: 'transmittal.upload' },
contract: { read: 'contract.read', create: 'contract.create', update: 'contract.update', delete: 'contract.delete' },
contract_dwg: { read: 'contract_dwg.read', create: 'contract_dwg.create', update: 'contract_dwg.update', delete: 'contract_dwg.delete' },
category: { read: 'category.read', create: 'category.create', update: 'category.update', delete: 'category.delete' },
volume: { read: 'volume.read', create: 'volume.create', update: 'volume.update', delete: 'volume.delete' },
permission: { read: 'permission.read' },
user: { read: 'user.read' },
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 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' },
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();
const PROFILE = (process.env.SEED_PROFILE || 'V5_DOT').toUpperCase();
export const PERM = PROFILE === 'V5_SNAKE' ? V5_SNAKE : V5_DOT;
export default PERM;
export const PERM = PROFILE === "V5_SNAKE" ? V5_SNAKE : V5_DOT;
export default PERM;

View File

@@ -1,13 +1,26 @@
// ESM
import mysql from 'mysql2/promise';
// 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)
import mysql from "mysql2/promise";
const {
DB_HOST = 'mariadb',
DB_PORT = '3306',
DB_USER = 'center',
DB_PASSWORD = 'Center#2025',
DB_NAME = 'dms',
DB_CONN_LIMIT = '10',
DB_HOST = "mariadb",
DB_PORT = "3306",
DB_USER = "center",
DB_PASSWORD = "Center#2025",
DB_NAME = "dms",
DB_CONN_LIMIT = "10",
} = process.env;
const pool = mysql.createPool({
@@ -20,7 +33,7 @@ const pool = mysql.createPool({
waitForConnections: true, // Recommended for handling connection spikes
namedPlaceholders: true,
dateStrings: true, // Keep dates as strings
timezone: 'Z', // Store and retrieve dates in UTC
timezone: "Z", // Store and retrieve dates in UTC
});
/**

View File

@@ -1,5 +1,12 @@
import { Sequelize } from 'sequelize';
import { config } from '../config.js';
// 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
import { Sequelize } from "sequelize";
import { config } from "../config.js";
export const sequelize = new Sequelize(
config.DB.NAME,
@@ -8,9 +15,9 @@ export const sequelize = new Sequelize(
{
host: config.DB.HOST,
port: config.DB.PORT,
dialect: 'mariadb',
dialect: "mariadb",
logging: false,
dialectOptions: { timezone: 'Z' },
dialectOptions: { timezone: "Z" },
define: {
freezeTableName: true,
underscored: false,
@@ -20,11 +27,11 @@ export const sequelize = new Sequelize(
}
);
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';
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 const User = UserModel(sequelize);
export const Role = RoleModel(sequelize);
@@ -32,11 +39,27 @@ export const Permission = PermissionModel(sequelize);
export const UserRole = UserRoleModel(sequelize);
export const RolePermission = RolePermissionModel(sequelize);
User.belongsToMany(Role, { through: UserRole, foreignKey: 'user_id', otherKey: 'role_id' });
Role.belongsToMany(User, { through: UserRole, foreignKey: 'role_id', otherKey: 'user_id' });
User.belongsToMany(Role, {
through: UserRole,
foreignKey: "user_id",
otherKey: "role_id",
});
Role.belongsToMany(User, {
through: UserRole,
foreignKey: "role_id",
otherKey: "user_id",
});
Role.belongsToMany(Permission, { through: RolePermission, foreignKey: 'role_id', otherKey: 'permission_id' });
Permission.belongsToMany(Role, { through: RolePermission, foreignKey: 'permission_id', otherKey: 'role_id' });
Role.belongsToMany(Permission, {
through: RolePermission,
foreignKey: "role_id",
otherKey: "permission_id",
});
Permission.belongsToMany(Role, {
through: RolePermission,
foreignKey: "permission_id",
otherKey: "role_id",
});
export async function dbReady() {
await sequelize.authenticate();

View File

@@ -1,6 +1,67 @@
// src/index.js (ESM)
// -------------------
// Node >= 18, Express 4/5 compatible
// 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)
// ==========================
import fs from "node:fs";
import path from "node:path";

View File

@@ -1,5 +1,11 @@
import { sequelize } from '../db/sequelize.js';
import UPRModel from '../db/models/UserProjectRole.js';
// FILE: src/middleware/abac.js
// ABAC: Attribute-Based Access Control middleware helpers
// - Project-scoped access control base on user_project_roles + permissions
// - Requires req.user.roles and req.user.permissions to be populated (e.g. via auth.js with enrichment)
// - Uses UserProjectRole model to check project membership
import { sequelize } from "../db/sequelize.js";
import UPRModel from "../db/models/UserProjectRole.js";
/**
* ดึง project_id ที่ผู้ใช้เข้าถึงได้ (จาก user_project_roles)
@@ -7,7 +13,7 @@ import UPRModel from '../db/models/UserProjectRole.js';
export async function getUserProjectIds(user_id) {
const UPR = UPRModel(sequelize);
const rows = await UPR.findAll({ where: { user_id } });
return [...new Set(rows.map(r => r.project_id))];
return [...new Set(rows.map((r) => r.project_id))];
}
/**
@@ -22,25 +28,32 @@ export async function getUserProjectIds(user_id) {
export function projectScopedView(moduleName) {
return async (req, res, next) => {
const roles = req.user?.roles || [];
const isAdmin = roles.includes('Admin');
const isAdmin = roles.includes("Admin");
const permName = `${moduleName}:view`;
const hasViewPerm = (req.user?.permissions || []).includes(permName);
// Admin ผ่านได้เสมอ
if (isAdmin) return next();
const qProjectId = req.query?.project_id ? Number(req.query.project_id) : null;
const qProjectId = req.query?.project_id
? Number(req.query.project_id)
: null;
const memberProjects = await getUserProjectIds(req.user?.user_id);
if (qProjectId) {
// ต้องเป็นสมาชิกโปรเจ็กต์นั้น หรือมี perm view
if (hasViewPerm || memberProjects.includes(qProjectId)) return next();
return res.status(403).json({ error: 'Forbidden: not a member of project' });
return res
.status(403)
.json({ error: "Forbidden: not a member of project" });
} else {
// ไม่มี project_id: ถ้ามี perm view → อนุญาตทั้งหมด
// ถ้าไม่มี perm view → จำกัดด้วยรายการโปรเจ็กต์ที่เป็นสมาชิก (บันทึกไว้ใน req.abac.filterProjectIds)
if (hasViewPerm) return next();
if (!memberProjects.length) return res.status(403).json({ error: 'Forbidden: no accessible projects' });
if (!memberProjects.length)
return res
.status(403)
.json({ error: "Forbidden: no accessible projects" });
req.abac = req.abac || {};
req.abac.filterProjectIds = memberProjects;
return next();
@@ -48,7 +61,6 @@ export function projectScopedView(moduleName) {
};
}
/**
* บังคับเป็นสมาชิกโปรเจ็กต์จากค่า project_id ใน body
* ใช้กับ create endpoints
@@ -56,12 +68,13 @@ export function projectScopedView(moduleName) {
export function requireProjectMembershipFromBody() {
return async (req, res, next) => {
const roles = req.user?.roles || [];
const isAdmin = roles.includes('Admin');
const isAdmin = roles.includes("Admin");
if (isAdmin) return next();
const pid = Number(req.body?.project_id);
if (!pid) return res.status(400).json({ error: 'project_id required' });
if (!pid) return res.status(400).json({ error: "project_id required" });
const memberProjects = await getUserProjectIds(req.user?.user_id);
if (!memberProjects.includes(pid)) return res.status(403).json({ error: 'Forbidden: not a project member' });
if (!memberProjects.includes(pid))
return res.status(403).json({ error: "Forbidden: not a project member" });
next();
};
}
@@ -71,19 +84,20 @@ export function requireProjectMembershipFromBody() {
* opts: { modelLoader: (sequelize)=>Model, idParam: 'id', projectField: 'project_id' }
*/
export function requireProjectMembershipByRecord(opts) {
const { modelLoader, idParam='id', projectField='project_id' } = opts;
const { modelLoader, idParam = "id", projectField = "project_id" } = opts;
return async (req, res, next) => {
const roles = req.user?.roles || [];
const isAdmin = roles.includes('Admin');
const isAdmin = roles.includes("Admin");
if (isAdmin) return next();
const id = Number(req.params[idParam]);
if (!id) return res.status(400).json({ error: 'Invalid id' });
if (!id) return res.status(400).json({ error: "Invalid id" });
const Model = modelLoader(sequelize);
const row = await Model.findByPk(id);
if (!row) return res.status(404).json({ error: 'Not found' });
if (!row) return res.status(404).json({ error: "Not found" });
const pid = Number(row[projectField]);
const memberProjects = await getUserProjectIds(req.user?.user_id);
if (!memberProjects.includes(pid)) return res.status(403).json({ error: 'Forbidden: not a project member' });
if (!memberProjects.includes(pid))
return res.status(403).json({ error: "Forbidden: not a project member" });
next();
};
}
@@ -94,10 +108,13 @@ export function requireProjectMembershipByRecord(opts) {
export function requireProjectIdQuery() {
return async (req, res, next) => {
const roles = req.user?.roles || [];
const isAdmin = roles.includes('Admin');
const isAdmin = roles.includes("Admin");
if (isAdmin) return next();
const qProjectId = req.query?.project_id ? Number(req.query.project_id) : null;
if (!qProjectId) return res.status(400).json({ error: 'project_id query required' });
const qProjectId = req.query?.project_id
? Number(req.query.project_id)
: null;
if (!qProjectId)
return res.status(400).json({ error: "project_id query required" });
next();
};
}

View File

@@ -1,37 +1,50 @@
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
import { User, Role, UserRole } from '../db/sequelize.js';
// FILE: src/middleware/auth.js
// Authentication & Authorization middleware
// - JWT-based authentication
// - Role & Permission enrichment
// - RBAC (Role-Based Access Control) helpers
// - Requires User, Role, Permission, UserRole, RolePermission models
import jwt from "jsonwebtoken";
import { config } from "../config.js";
import { User, Role, UserRole } from "../db/sequelize.js";
export function signAccessToken(payload) {
return jwt.sign(payload, config.JWT.SECRET, { expiresIn: config.JWT.EXPIRES_IN });
return jwt.sign(payload, config.JWT.SECRET, {
expiresIn: config.JWT.EXPIRES_IN,
});
}
export function signRefreshToken(payload) {
return jwt.sign(payload, config.JWT.REFRESH_SECRET, { expiresIn: config.JWT.REFRESH_EXPIRES_IN });
return jwt.sign(payload, config.JWT.REFRESH_SECRET, {
expiresIn: config.JWT.REFRESH_EXPIRES_IN,
});
}
export function requireAuth(req, res, next) {
if (req.path === '/health') return next(); // อนุญาต health เสมอ
const hdr = req.headers.authorization || '';
const token = hdr.startsWith('Bearer ') ? hdr.slice(7) : null;
if (!token) return res.status(401).json({ error: 'Missing token' });
if (req.path === "/health") return next(); // อนุญาต health เสมอ
const hdr = req.headers.authorization || "";
const token = hdr.startsWith("Bearer ") ? hdr.slice(7) : null;
if (!token) return res.status(401).json({ error: "Missing token" });
try {
req.user = jwt.verify(token, config.JWT.SECRET);
next();
} catch {
return res.status(401).json({ error: 'Invalid/Expired token' });
return res.status(401).json({ error: "Invalid/Expired token" });
}
}
export async function enrichRoles(req, _res, next) {
if (!req.user?.user_id) return next();
const rows = await UserRole.findAll({ where: { user_id: req.user.user_id }, include: [{ model: Role }] })
.catch(() => []);
req.user.roles = rows.map(r => r.role?.role_name).filter(Boolean);
const rows = await UserRole.findAll({
where: { user_id: req.user.user_id },
include: [{ model: Role }],
}).catch(() => []);
req.user.roles = rows.map((r) => r.role?.role_name).filter(Boolean);
next();
}
export function hasPerm(req, perm) {
const set = new Set(req?.user?.permissions || []);
return set.has(perm);
}
}

View File

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

View File

@@ -1,7 +1,13 @@
// FILE: src/middleware/errorHandler.js
// Error handling middleware
// - 404 Not Found handler
// - General error handler
// - Should be the last middleware added
export function notFound(_req, res, _next) {
res.status(404).json({ error: 'Not Found' });
res.status(404).json({ error: "Not Found" });
}
export function errorHandler(err, _req, res, _next) {
console.error(err);
res.status(500).json({ error: 'Internal Server Error' });
res.status(500).json({ error: "Internal Server Error" });
}

View File

@@ -1,15 +1,23 @@
// loadPrincipal.js
import { loadPrincipal } from '../utils/rbac.js';
// FILE: src/middleware/loadPrincipal.js
// 03.2 4) เพิ่ม middleware loadPrincipal (ใหม่)
// นำ middleware นี้ไปใส่ หลัง verify JWT เสมอ เช่น app.use('/api', authJwt(), loadPrincipalMw(), routes)
// Load principal (roles + permissions) middleware
// - Uses rbac.js utility to load principal info
// - Attaches to req.principal
// - Requires req.user.user_id to be populated (e.g. via auth.js or authJwt.js)
import { loadPrincipal } from "../utils/rbac.js";
export function loadPrincipalMw() {
return async (req, res, next) => {
try {
if (!req.user?.user_id) return res.status(401).json({ error: 'Unauthenticated' });
if (!req.user?.user_id)
return res.status(401).json({ error: "Unauthenticated" });
req.principal = await loadPrincipal(req.user.user_id);
next();
} catch (err) {
console.error('loadPrincipal error', err);
res.status(500).json({ error: 'Failed to load principal' });
console.error("loadPrincipal error", err);
res.status(500).json({ error: "Failed to load principal" });
}
};
}

View File

@@ -1,3 +1,8 @@
// FILE: src/middleware/permGuard.js
// Permission guard middleware
// - Checks if user has required permissions
// - Requires req.user.permissions to be populated (e.g. via auth.js or authJwt.js with enrichment)
/**
* requirePerm('rfa:create') => ตรวจว่ามี permission นี้ใน req.user.permissions
* ต้องแน่ใจว่าเรียก enrichPermissions() มาก่อน หรือคำนวณที่จุดเข้าใช้งาน
@@ -5,8 +10,8 @@
export function requirePerm(...allowedPerms) {
return (req, res, next) => {
const perms = req.user?.permissions || [];
const ok = perms.some(p => allowedPerms.includes(p));
if (!ok) return res.status(403).json({ error: 'Forbidden' });
const ok = perms.some((p) => allowedPerms.includes(p));
if (!ok) return res.status(403).json({ error: "Forbidden" });
next();
};
}

View File

@@ -1,4 +1,9 @@
import { Role, Permission, UserRole, RolePermission } from '../db/sequelize.js';
// FILE: src/middleware/permissions.js
// Permission calculation and enrichment middleware
// - Computes effective permissions for a user based on their roles
// - Attaches permissions to req.user.permissions
import { Role, Permission, UserRole, RolePermission } from "../db/sequelize.js";
/**
* คืนชุด permission (string[]) ของ user_id
@@ -6,16 +11,16 @@ import { Role, Permission, UserRole, RolePermission } from '../db/sequelize.js';
export async function computeEffectivePermissions(user_id) {
// ดึง roles ของผู้ใช้
const userRoles = await UserRole.findAll({ where: { user_id } });
const roleIds = userRoles.map(r => r.role_id);
const roleIds = userRoles.map((r) => r.role_id);
if (!roleIds.length) return [];
// ดึง permission ผ่าน role_permissions
const rp = await RolePermission.findAll({ where: { role_id: roleIds } });
const permIds = [...new Set(rp.map(x => x.permission_id))];
const permIds = [...new Set(rp.map((x) => x.permission_id))];
if (!permIds.length) return [];
const perms = await Permission.findAll({ where: { permission_id: permIds } });
return [...new Set(perms.map(p => p.permission_name))];
return [...new Set(perms.map((p) => p.permission_name))];
}
/**

View File

@@ -1,8 +1,13 @@
// FILE: src/middleware/rbac.js
// RBAC: Role-Based Access Control middleware helpers
// - Role and Permission guard middleware
// - Requires req.user.roles and req.user.permissions to be populated (e.g. via auth.js with enrichment)
export function requireRole(...allowed) {
return (req, res, next) => {
const roles = req.user?.roles || [];
const ok = roles.some(r => allowed.includes(r));
if (!ok) return res.status(403).json({ error: 'Forbidden' });
const ok = roles.some((r) => allowed.includes(r));
if (!ok) return res.status(403).json({ error: "Forbidden" });
next();
};
}
@@ -10,8 +15,8 @@ export function requireRole(...allowed) {
export function requirePermission(...allowedPerms) {
return (req, res, next) => {
const perms = req.user?.permissions || [];
const ok = perms.some(p => allowedPerms.includes(p));
if (!ok) return res.status(403).json({ error: 'Forbidden' });
const ok = perms.some((p) => allowedPerms.includes(p));
if (!ok) return res.status(403).json({ error: "Forbidden" });
next();
};
}

View File

@@ -1,25 +1,37 @@
// src/middleware/requirePerm.js
import { canPerform } from '../utils/rbac.js';
// FILE: src/middleware/requirePerm.js
// 03.2 4) เพิ่ม middleware requirePerm (ใหม่)
// นำ middleware นี้ไปใส่ หลัง loadPrincipal เสมอ เช่น app.use('/api', authJwt(), loadPrincipalMw(), requirePerm('correspondence.create', {scope:'org', getOrgId: req=>...}), routes)
// หรือใส่ใน route เดี่ยวๆ ก็ได้ เช่น router.post('/', requirePerm('correspondence.create', {scope:'org', getOrgId: req=>...}), (req,res)=>{...})
// Permission requirement middleware with scope support
// - Uses canPerform() utility from rbac.js
// - Supports global, org, and project scopes
// - Requires req.principal to be populated (e.g. via loadPrincipal middleware)
import { canPerform } from "../utils/rbac.js";
/**
* requirePerm('correspondence.create', { scope: 'org', getOrgId: req => ... })
* scope: 'global' | 'org' | 'project'
*/
export function requirePerm(permCode, { scope = 'global', getOrgId = null, getProjectId = null } = {}) {
export function requirePerm(
permCode,
{ scope = "global", getOrgId = null, getProjectId = null } = {}
) {
return async (req, res, next) => {
try {
const orgId = getOrgId ? await getOrgId(req) : null;
const projectId = getProjectId ? await getProjectId(req) : null;
if (canPerform(req.principal, permCode, { scope, orgId, projectId })) return next();
if (canPerform(req.principal, permCode, { scope, orgId, projectId }))
return next();
return res.status(403).json({
error: 'FORBIDDEN',
error: "FORBIDDEN",
message: `Require ${permCode} (${scope}-scoped)`,
});
} catch (e) {
console.error('requirePerm error', e);
res.status(500).json({ error: 'Permission check error' });
console.error("requirePerm error", e);
res.status(500).json({ error: "Permission check error" });
}
};
}

View File

@@ -1,4 +1,12 @@
// src/routes/admin.js
// 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";

View File

@@ -1,62 +1,79 @@
// src/routes/auth.js
// FILE: src/routes/auth.js (ESM) — Bearer only, refresh via header/body, forgot/reset password
// 03.2 เพิ่ม auth.js และ lookup.js ให้สอดคล้อง RBAC/permission_code
// ตาม src/config/permissions.js) และอ่าน scope จาก DB เสมอ
/*สมมติว่ามีตาราง password_resets สำหรับเก็บโทเคนรีเซ็ต:
password_resets(
id BIGINT PK, user_id BIGINT, token_hash CHAR(64),
expires_at DATETIME, used_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
INDEX(token_hash), INDEX(user_id), INDEX(expires_at)
*/
import { Router } from "express";
import jwt from "jsonwebtoken";
import sql from "../db/index.js";
import crypto from "node:crypto"; // ถ้าต้องการ timingSafeEqual
import bcrypt from "bcryptjs"; // ถ้า password_hash เป็น bcrypt (แนะนำ)
import bcrypt from "bcryptjs";
import crypto from "node:crypto";
const r = Router();
// ตั้งค่า JWT (อย่าใช้ .env ในโปรดักชันของคุณ → ใส่ผ่าน docker-compose)
const JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || "dev-access-secret";
const JWT_REFRESH_SECRET =
process.env.JWT_REFRESH_SECRET || "dev-refresh-secret";
const ACCESS_TTL_MS = 30 * 60 * 1000; // 30 นาที
const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 วัน
function cookieOpts(maxAge) {
return {
httpOnly: true,
secure: true, // ใช้งานจริงหลัง HTTPS
sameSite: "lax",
path: "/",
maxAge,
// domain: ".np-dms.work", // ถ้าต้องการใช้ข้าม subdomain ให้เปิด
};
}
/* =========================
* CONFIG & HELPERS
* ========================= */
// ใช้ค่าเดียวกับ middleware authJwt()
const JWT_SECRET = process.env.JWT_SECRET || "dev-secret";
const REFRESH_SECRET = process.env.REFRESH_SECRET || "dev-refresh-secret";
const ACCESS_TTL = process.env.ACCESS_TTL || "30m";
const REFRESH_TTL = process.env.REFRESH_TTL || "30d";
// อายุของ reset token (นาที)
const RESET_TTL_MIN = Number(process.env.RESET_TTL_MIN || 30);
function signAccessToken(user) {
return jwt.sign(
{ user_id: user.user_id, username: user.username },
JWT_ACCESS_SECRET,
{ expiresIn: "30m", issuer: "dms-backend" }
JWT_SECRET,
{ expiresIn: ACCESS_TTL, issuer: "dms-backend" }
);
}
function signRefreshToken(user) {
return jwt.sign({ user_id: user.user_id }, JWT_REFRESH_SECRET, {
expiresIn: "30d",
issuer: "dms-backend",
});
return jwt.sign(
{ user_id: user.user_id, username: user.username, t: "refresh" },
REFRESH_SECRET,
{ expiresIn: REFRESH_TTL, issuer: "dms-backend" }
);
}
function getBearer(req) {
const h = req.headers.authorization || "";
if (!h.startsWith("Bearer ")) return null;
const token = h.slice(7).trim();
return token || null;
}
async function findUserByUsername(username) {
const [rows] = await sql.query(
"SELECT user_id, username, email, password_hash FROM users WHERE username=? LIMIT 1",
`SELECT user_id, username, email, first_name, last_name, password_hash
FROM users WHERE username=? LIMIT 1`,
[username]
);
return rows?.[0] || null;
}
async function verifyPassword(plain, hash) {
// ถ้าใช้ bcrypt:
try {
return await bcrypt.compare(plain, hash);
} catch {
return false;
}
// ถ้าระบบคุณใช้ hash แบบอื่น ให้สลับมาใช้วิธีที่ตรงกับของจริง
async function findUserByEmail(email) {
const [rows] = await sql.query(
`SELECT user_id, username, email, first_name, last_name, password_hash
FROM users WHERE email=? LIMIT 1`,
[email]
);
return rows?.[0] || null;
}
/* =========================
* POST /api/auth/login
* - รับ username/password
* - ตรวจ bcrypt แล้วออก token+refresh_token (JSON)
* ========================= */
r.post("/login", async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
@@ -66,62 +83,169 @@ r.post("/login", async (req, res) => {
const user = await findUserByUsername(username);
if (!user) return res.status(401).json({ error: "INVALID_CREDENTIALS" });
const ok = await verifyPassword(password, user.password_hash);
const ok = await bcrypt.compare(password, user.password_hash || "");
if (!ok) return res.status(401).json({ error: "INVALID_CREDENTIALS" });
const access = signAccessToken(user);
const refresh = signRefreshToken(user);
res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS));
res.cookie("refresh_token", refresh, cookieOpts(REFRESH_TTL_MS));
const token = signAccessToken(user);
const refresh_token = signRefreshToken(user);
return res.json({
ok: true,
token,
refresh_token,
user: {
user_id: user.user_id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
},
});
});
/* =========================
* POST /api/auth/refresh
* - รองรับ refresh token จาก:
* 1) Authorization: Bearer <refresh_token>
* 2) req.body.refresh_token
* - ออก token ใหม่ + refresh ใหม่ (rotation)
* ========================= */
r.post("/refresh", async (req, res) => {
const refresh = req.cookies?.refresh_token || req.body?.refresh_token;
if (!refresh)
const fromHeader = getBearer(req);
const fromBody = (req.body || {}).refresh_token;
const refreshToken = fromHeader || fromBody;
if (!refreshToken) {
return res.status(400).json({ error: "REFRESH_TOKEN_REQUIRED" });
}
try {
const payload = jwt.verify(refresh, JWT_REFRESH_SECRET, {
const payload = jwt.verify(refreshToken, REFRESH_SECRET, {
issuer: "dms-backend",
});
// TODO: (ถ้ามี) ตรวจ blacklist/rotation store ของ refresh token
if (payload.t !== "refresh") throw new Error("bad token type");
// คืน user จากฐานข้อมูลจริงตาม payload.user_id
const [rows] = await sql.query(
"SELECT user_id, username, email FROM users WHERE user_id=? LIMIT 1",
const [[user]] = await sql.query(
`SELECT user_id, username, email, first_name, last_name
FROM users WHERE user_id=? LIMIT 1`,
[payload.user_id]
);
const user = rows?.[0];
if (!user) return res.status(401).json({ error: "USER_NOT_FOUND" });
// rotation: ออก access+refresh ใหม่
const access = signAccessToken(user);
const newRef = signRefreshToken(user);
// rotation
const token = signAccessToken(user);
const new_refresh = signRefreshToken(user);
res.cookie("access_token", access, cookieOpts(ACCESS_TTL_MS));
res.cookie("refresh_token", newRef, cookieOpts(REFRESH_TTL_MS));
return res.json({ ok: true });
} catch (e) {
return res.json({
token,
refresh_token: new_refresh,
user: {
user_id: user.user_id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
},
});
} catch {
return res.status(401).json({ error: "INVALID_REFRESH_TOKEN" });
}
});
r.post("/logout", (req, res) => {
res.clearCookie("access_token", { path: "/" });
res.clearCookie("refresh_token", { path: "/" });
/* =========================
* POST /api/auth/forgot-password
* - รับ username หรือ email อย่างใดอย่างหนึ่ง
* - สร้าง reset token แบบสุ่ม, เก็บ hash ใน DB พร้อมหมดอายุ
* - ส่งเสมอ {ok:true} เพื่อลด user enumeration
* - การ “ส่งอีเมล/ลิงก์รีเซ็ต” ให้ทำนอกระบบนี้ (เช่น n8n)
* ========================= */
r.post("/forgot-password", async (req, res) => {
const { username, email } = req.body || {};
// หา user จาก username หรือ email (ถ้ามีทั้งสอง จะให้ username มาก่อน)
let user = null;
if (username) user = await findUserByUsername(username);
if (!user && email) user = await findUserByEmail(email);
// สร้างโทเคน “เหมือนจริง” เสมอ (แต่ถ้าไม่เจอ user ก็ไม่บอก)
if (user) {
const raw = crypto.randomBytes(32).toString("hex"); // โทเคนดิบ (ส่งทางอีเมล)
const hash = crypto.createHash("sha256").update(raw).digest("hex"); // เก็บใน DB
const expires = new Date(Date.now() + RESET_TTL_MIN * 60 * 1000);
// ทำ invalid เก่า ๆ ของ user นี้ (optional)
await sql.query(
`UPDATE password_resets SET used_at=NOW()
WHERE user_id=? AND used_at IS NULL AND expires_at < NOW()`,
[user.user_id]
);
// บันทึก token ใหม่
await sql.query(
`INSERT INTO password_resets (user_id, token_hash, expires_at)
VALUES (?,?,?)`,
[user.user_id, hash, expires]
);
// TODO: ส่ง “raw token” ไปช่องทางปลอดภัย (เช่น n8n ส่งอีเมล)
// ตัวอย่างลิงก์ที่ frontend จะใช้:
// https://<frontend-domain>/reset-password?token=<raw>
// คุณสามารถต่อ webhook ไป n8n ได้ที่นี่ถ้าต้องการ
}
// ไม่บอกว่าเจอหรือไม่เจอ user
return res.json({ ok: true });
});
/* =========================
* POST /api/auth/reset-password
* - รับ token (จากลิงก์ในอีเมล) + new_password
* - ตรวจ token_hash กับ DB, หมดอายุหรือถูกใช้ไปแล้วหรือยัง
* - เปลี่ยนรหัสผ่าน/ปิดใช้ token
* ========================= */
r.post("/reset-password", async (req, res) => {
const { token, new_password } = req.body || {};
if (!token || !new_password) {
return res.status(400).json({ error: "TOKEN_AND_NEW_PASSWORD_REQUIRED" });
}
const token_hash = crypto.createHash("sha256").update(token).digest("hex");
const [[row]] = await sql.query(
`SELECT id, user_id, expires_at, used_at
FROM password_resets
WHERE token_hash=? LIMIT 1`,
[token_hash]
);
if (!row) return res.status(400).json({ error: "INVALID_TOKEN" });
if (row.used_at) return res.status(400).json({ error: "TOKEN_ALREADY_USED" });
if (new Date(row.expires_at).getTime() < Date.now()) {
return res.status(400).json({ error: "TOKEN_EXPIRED" });
}
// เปลี่ยนรหัสผ่าน
const salt = await bcrypt.genSalt(10);
const hash = await bcrypt.hash(new_password, salt);
await sql.query(`UPDATE users SET password_hash=? WHERE user_id=?`, [
hash,
row.user_id,
]);
// ปิดใช้ token นี้
await sql.query(`UPDATE password_resets SET used_at=NOW() WHERE id=?`, [
row.id,
]);
return res.json({ ok: true });
});
/* =========================
* POST /api/auth/logout — stateless
* - frontend ลบ token เอง
* ========================= */
r.post("/logout", (_req, res) => {
return res.json({ ok: true });
});
export default r;
// หมายเหตุ: คุณอาจเพิ่ม /register, /forgot-password, /reset-password ตามต้องการ
// แต่ในโปรเจกต์นี้จะให้แอดมินสร้างบัญชีผู้ใช้ผ่าน /api/users แทน

View File

@@ -1,4 +1,8 @@
// src/routes/auth_extras.js
// 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)
import jwt from "jsonwebtoken";
const JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || "dev-access-secret";

View File

@@ -1,66 +1,86 @@
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import PERM from '../config/permissions.js';
// FILE: src/routes/categories.js
// 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.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.post(
"/categories",
requirePerm(PERM.category.create, { scope: "global" }),
async (req, res) => {
const { cat_code, cat_name } = req.body;
const [rs] = await sql.query(
"INSERT INTO categories (cat_code, cat_name) VALUES (?,?)",
[cat_code, cat_name]
);
res.json({ cat_id: rs.insertId });
}
);
r.put('/categories/:id',
requirePerm(PERM.category.update, { scope: 'global' }),
async (req, res) => {
const id = Number(req.params.id);
const { cat_name } = req.body;
await sql.query('UPDATE categories SET cat_name=? WHERE cat_id=?', [cat_name, id]);
res.json({ ok: 1 });
}
r.put(
"/categories/:id",
requirePerm(PERM.category.update, { scope: "global" }),
async (req, res) => {
const id = Number(req.params.id);
const { cat_name } = req.body;
await sql.query("UPDATE categories SET cat_name=? WHERE cat_id=?", [
cat_name,
id,
]);
res.json({ ok: 1 });
}
);
r.delete('/categories/:id',
requirePerm(PERM.category.delete, { scope: 'global' }),
async (req, res) => {
const id = Number(req.params.id);
await sql.query('DELETE FROM categories WHERE cat_id=?', [id]);
res.json({ ok: 1 });
}
r.delete(
"/categories/:id",
requirePerm(PERM.category.delete, { scope: "global" }),
async (req, res) => {
const id = Number(req.params.id);
await sql.query("DELETE FROM categories WHERE cat_id=?", [id]);
res.json({ ok: 1 });
}
);
// Subcategories (belong to categories)
r.get('/subcategories',
requirePerm(PERM.category.read, { scope: 'global' }),
async (req, res) => {
const { cat_id } = req.query;
let sqlText = 'SELECT * FROM subcategories';
const params = [];
if (cat_id) { sqlText += ' WHERE cat_id=?'; params.push(Number(cat_id)); }
sqlText += ' ORDER BY sub_cat_id DESC';
const [rows] = await sql.query(sqlText, params);
res.json(rows);
}
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);
}
);
export default r;
export default r;

View File

@@ -1,74 +1,147 @@
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import { buildScopeWhere, ownerResolvers } from '../utils/scope.js';
import PERM from '../config/permissions.js';
// FILE: src/routes/contract_dwg.js
// 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');
const OWN = ownerResolvers(sql, "contract_dwg", "id");
// LIST mappings
r.get('/',
requirePerm(PERM.contract_dwg.read, { scope: 'global' }),
async (req, res) => {
const { project_id, org_id, condwg_no, limit=50, offset=0 } = req.query;
const base = buildScopeWhere(req.principal, { tableAlias: 'm', orgColumn: 'm.org_id', projectColumn: 'm.project_id', permCode: PERM.contract_dwg.read, preferProject: true });
const extra = [];
const params = { ...base.params, limit: Number(limit), offset: Number(offset) };
if (project_id) { extra.push('m.project_id = :project_id'); params.project_id = Number(project_id); }
if (org_id) { extra.push('m.org_id = :org_id'); params.org_id = Number(org_id); }
if (condwg_no) { extra.push('m.condwg_no = :condwg_no'); params.condwg_no = condwg_no; }
const where = [base.where, ...extra].filter(Boolean).join(' AND ');
const [rows] = await sql.query(`SELECT m.* FROM contract_dwg m WHERE ${where} ORDER BY m.id DESC LIMIT :limit OFFSET :offset`, params);
res.json(rows);
}
r.get(
"/",
requirePerm(PERM.contract_dwg.read, { scope: "global" }),
async (req, res) => {
const { project_id, org_id, condwg_no, limit = 50, offset = 0 } = req.query;
const base = buildScopeWhere(req.principal, {
tableAlias: "m",
orgColumn: "m.org_id",
projectColumn: "m.project_id",
permCode: PERM.contract_dwg.read,
preferProject: true,
});
const extra = [];
const params = {
...base.params,
limit: Number(limit),
offset: Number(offset),
};
if (project_id) {
extra.push("m.project_id = :project_id");
params.project_id = Number(project_id);
}
if (org_id) {
extra.push("m.org_id = :org_id");
params.org_id = Number(org_id);
}
if (condwg_no) {
extra.push("m.condwg_no = :condwg_no");
params.condwg_no = condwg_no;
}
const where = [base.where, ...extra].filter(Boolean).join(" AND ");
const [rows] = await sql.query(
`SELECT m.* FROM contract_dwg m WHERE ${where} ORDER BY m.id DESC LIMIT :limit OFFSET :offset`,
params
);
res.json(rows);
}
);
// 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);
}
r.get(
"/:id",
requirePerm(PERM.contract_dwg.read, {
scope: "org",
getOrgId: OWN.getOrgIdById,
}),
async (req, res) => {
const id = Number(req.params.id);
const [[row]] = await sql.query("SELECT * FROM contract_dwg WHERE id=?", [
id,
]);
if (!row) return res.status(404).json({ error: "Not found" });
res.json(row);
}
);
// CREATE mapping (1 drawing per contract or per rule)
r.post('/',
requirePerm(PERM.contract_dwg.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
async (req, res) => {
const { org_id, project_id, condwg_no, title, drawing_id, volume_id, sub_cat_id, sub_no, remark } = req.body;
const [rs] = await sql.query(`INSERT INTO contract_dwg (org_id, project_id, condwg_no, title, drawing_id, volume_id, sub_cat_id, sub_no, remark, created_by) VALUES (?,?,?,?,?,?,?,?,?,?)`, [org_id, project_id, condwg_no, title, drawing_id, volume_id, sub_cat_id, sub_no, remark, req.principal.userId]);
res.json({ id: rs.insertId });
}
r.post(
"/",
requirePerm(PERM.contract_dwg.create, {
scope: "org",
getOrgId: async (req) => req.body?.org_id ?? null,
}),
async (req, res) => {
const {
org_id,
project_id,
condwg_no,
title,
drawing_id,
volume_id,
sub_cat_id,
sub_no,
remark,
} = req.body;
const [rs] = await sql.query(
`INSERT INTO contract_dwg (org_id, project_id, condwg_no, title, drawing_id, volume_id, sub_cat_id, sub_no, remark, created_by) VALUES (?,?,?,?,?,?,?,?,?,?)`,
[
org_id,
project_id,
condwg_no,
title,
drawing_id,
volume_id,
sub_cat_id,
sub_no,
remark,
req.principal.userId,
]
);
res.json({ id: rs.insertId });
}
);
// UPDATE
r.put('/:id',
requirePerm(PERM.contract_dwg.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
const id = Number(req.params.id);
const { title, remark } = req.body;
await sql.query('UPDATE contract_dwg SET title=?, remark=? WHERE id=?', [title, remark, id]);
res.json({ ok: 1 });
}
r.put(
"/:id",
requirePerm(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 });
}
);
// 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(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 });
}
);
export default r;
export default r;

View File

@@ -1,72 +1,130 @@
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import { buildScopeWhere, ownerResolvers } from '../utils/scope.js';
import PERM from '../config/permissions.js';
// FILE: src/routes/contracts.js
// 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');
const OWN = ownerResolvers(sql, "contracts", "id");
r.get('/',
requirePerm(PERM.contract.read, { scope: 'global' }),
async (req, res) => {
const { project_id, org_id, contract_no, q, limit = 50, offset = 0 } = req.query;
const base = buildScopeWhere(req.principal, { tableAlias: 'c', orgColumn: 'c.org_id', projectColumn: 'c.project_id', permCode: PERM.contract.read, preferProject: true });
const extra = [];
const params = { ...base.params, limit: Number(limit), offset: Number(offset) };
if (project_id) { extra.push('c.project_id = :project_id'); params.project_id = Number(project_id); }
if (org_id) { extra.push('c.org_id = :org_id'); params.org_id = Number(org_id); }
if (contract_no){ extra.push('c.contract_no = :contract_no'); params.contract_no = contract_no; }
if (q) { extra.push('(c.contract_no LIKE :q OR c.title LIKE :q)'); params.q = `%${q}%`; }
const where = [base.where, ...extra].filter(Boolean).join(' AND ');
const [rows] = await sql.query(`SELECT c.* FROM contracts c WHERE ${where} ORDER BY c.id DESC LIMIT :limit OFFSET :offset`, params);
res.json(rows);
}
r.get(
"/",
requirePerm(PERM.contract.read, { scope: "global" }),
async (req, res) => {
const {
project_id,
org_id,
contract_no,
q,
limit = 50,
offset = 0,
} = req.query;
const base = buildScopeWhere(req.principal, {
tableAlias: "c",
orgColumn: "c.org_id",
projectColumn: "c.project_id",
permCode: PERM.contract.read,
preferProject: true,
});
const extra = [];
const params = {
...base.params,
limit: Number(limit),
offset: Number(offset),
};
if (project_id) {
extra.push("c.project_id = :project_id");
params.project_id = Number(project_id);
}
if (org_id) {
extra.push("c.org_id = :org_id");
params.org_id = Number(org_id);
}
if (contract_no) {
extra.push("c.contract_no = :contract_no");
params.contract_no = contract_no;
}
if (q) {
extra.push("(c.contract_no LIKE :q OR c.title LIKE :q)");
params.q = `%${q}%`;
}
const where = [base.where, ...extra].filter(Boolean).join(" AND ");
const [rows] = await sql.query(
`SELECT c.* FROM contracts c WHERE ${where} ORDER BY c.id DESC LIMIT :limit OFFSET :offset`,
params
);
res.json(rows);
}
);
r.get('/:id',
requirePerm(PERM.contract.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
const id = Number(req.params.id);
const [[row]] = await sql.query('SELECT * FROM contracts WHERE id=?', [id]);
if (!row) return res.status(404).json({ error: 'Not found' });
res.json(row);
}
r.get(
"/:id",
requirePerm(PERM.contract.read, { scope: "org", getOrgId: OWN.getOrgIdById }),
async (req, res) => {
const id = Number(req.params.id);
const [[row]] = await sql.query("SELECT * FROM contracts WHERE id=?", [id]);
if (!row) return res.status(404).json({ error: "Not found" });
res.json(row);
}
);
r.post('/',
requirePerm(PERM.contract.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
async (req, res) => {
const { org_id, project_id, contract_no, title, status } = req.body;
const [rs] = await sql.query(`INSERT INTO contracts (org_id, project_id, contract_no, title, status, created_by) VALUES (?,?,?,?,?,?)`, [org_id, project_id, contract_no, title, status, req.principal.userId]);
res.json({ id: rs.insertId });
}
r.post(
"/",
requirePerm(PERM.contract.create, {
scope: "org",
getOrgId: async (req) => req.body?.org_id ?? null,
}),
async (req, res) => {
const { org_id, project_id, contract_no, title, status } = req.body;
const [rs] = await sql.query(
`INSERT INTO contracts (org_id, project_id, contract_no, title, status, created_by) VALUES (?,?,?,?,?,?)`,
[org_id, project_id, contract_no, title, status, req.principal.userId]
);
res.json({ id: rs.insertId });
}
);
r.put('/:id',
requirePerm(PERM.contract.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
const id = Number(req.params.id);
const { title, status } = req.body;
await sql.query('UPDATE contracts SET title=?, status=? WHERE id=?', [title, status, id]);
res.json({ ok: 1 });
}
r.put(
"/:id",
requirePerm(PERM.contract.update, {
scope: "org",
getOrgId: OWN.getOrgIdById,
}),
async (req, res) => {
const id = Number(req.params.id);
const { title, status } = req.body;
await sql.query("UPDATE contracts SET title=?, status=? WHERE id=?", [
title,
status,
id,
]);
res.json({ ok: 1 });
}
);
r.delete('/:id',
requirePerm(PERM.contract.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
const id = Number(req.params.id);
await sql.query('DELETE FROM contracts WHERE id=?', [id]);
res.json({ ok: 1 });
}
r.delete(
"/:id",
requirePerm(PERM.contract.delete, {
scope: "org",
getOrgId: OWN.getOrgIdById,
}),
async (req, res) => {
const id = Number(req.params.id);
await sql.query("DELETE FROM contracts WHERE id=?", [id]);
res.json({ ok: 1 });
}
);
export default r;
export default r;

View File

@@ -1,74 +1,124 @@
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import { buildScopeWhere, ownerResolvers } from '../utils/scope.js';
import PERM from '../config/permissions.js';
// FILE: 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
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');
const OWN = ownerResolvers(sql, "correspondences", "id");
r.get('/',
requirePerm(PERM.correspondence.read, { scope: 'global' }),
async (req, res) => {
const { project_id, org_id, q, limit = 50, offset = 0 } = req.query;
const base = buildScopeWhere(req.principal, {
tableAlias: 'c', orgColumn: 'c.org_id', projectColumn: 'c.project_id',
permCode: PERM.correspondence.read, preferProject: true,
});
const extra = [];
const params = { ...base.params, limit: Number(limit), offset: Number(offset) };
if (project_id) { extra.push('c.project_id = :project_id'); params.project_id = Number(project_id); }
if (org_id) { extra.push('c.org_id = :org_id'); params.org_id = Number(org_id); }
if (q) { extra.push('(c.corr_no LIKE :q OR c.subject LIKE :q)'); params.q = `%${q}%`; }
const where = [base.where, ...extra].join(' AND ');
const [rows] = await sql.query(`SELECT c.* FROM correspondences c WHERE ${where} ORDER BY c.id DESC LIMIT :limit OFFSET :offset`, params);
res.json(rows);
}
r.get(
"/",
requirePerm(PERM.correspondence.read, { scope: "global" }),
async (req, res) => {
const { project_id, org_id, q, limit = 50, offset = 0 } = req.query;
const base = buildScopeWhere(req.principal, {
tableAlias: "c",
orgColumn: "c.org_id",
projectColumn: "c.project_id",
permCode: PERM.correspondence.read,
preferProject: true,
});
const extra = [];
const params = {
...base.params,
limit: Number(limit),
offset: Number(offset),
};
if (project_id) {
extra.push("c.project_id = :project_id");
params.project_id = Number(project_id);
}
if (org_id) {
extra.push("c.org_id = :org_id");
params.org_id = Number(org_id);
}
if (q) {
extra.push("(c.corr_no LIKE :q OR c.subject LIKE :q)");
params.q = `%${q}%`;
}
const where = [base.where, ...extra].join(" AND ");
const [rows] = await sql.query(
`SELECT c.* FROM correspondences c WHERE ${where} ORDER BY c.id DESC LIMIT :limit OFFSET :offset`,
params
);
res.json(rows);
}
);
r.get('/:id',
requirePerm(PERM.correspondence.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
const id = Number(req.params.id);
const [[row]] = await sql.query('SELECT * FROM correspondences WHERE id=?', [id]);
if (!row) return res.status(404).json({ error: 'Not found' });
res.json(row);
}
r.get(
"/:id",
requirePerm(PERM.correspondence.read, {
scope: "org",
getOrgId: OWN.getOrgIdById,
}),
async (req, res) => {
const id = Number(req.params.id);
const [[row]] = await sql.query(
"SELECT * FROM correspondences WHERE id=?",
[id]
);
if (!row) return res.status(404).json({ error: "Not found" });
res.json(row);
}
);
r.post('/',
requirePerm(PERM.correspondence.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
async (req, res) => {
const { org_id, project_id, corr_no, subject, status } = req.body;
const [rs] = await sql.query(`INSERT INTO correspondences (org_id, project_id, corr_no, subject, status, created_by) VALUES (?,?,?,?,?,?)`, [org_id, project_id, corr_no, subject, status, req.principal.userId]);
res.json({ id: rs.insertId });
}
r.post(
"/",
requirePerm(PERM.correspondence.create, {
scope: "org",
getOrgId: async (req) => req.body?.org_id ?? null,
}),
async (req, res) => {
const { org_id, project_id, corr_no, subject, status } = req.body;
const [rs] = await sql.query(
`INSERT INTO correspondences (org_id, project_id, corr_no, subject, status, created_by) VALUES (?,?,?,?,?,?)`,
[org_id, project_id, corr_no, subject, status, req.principal.userId]
);
res.json({ id: rs.insertId });
}
);
r.put('/:id',
requirePerm(PERM.correspondence.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
const id = Number(req.params.id);
const { subject, status } = req.body;
await sql.query('UPDATE correspondences SET subject=?, status=? WHERE id=?', [subject, status, id]);
res.json({ ok: 1 });
}
r.put(
"/:id",
requirePerm(PERM.correspondence.update, {
scope: "org",
getOrgId: OWN.getOrgIdById,
}),
async (req, res) => {
const id = Number(req.params.id);
const { subject, status } = req.body;
await sql.query(
"UPDATE correspondences SET subject=?, status=? WHERE id=?",
[subject, status, id]
);
res.json({ ok: 1 });
}
);
r.delete('/:id',
requirePerm(PERM.correspondence.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
async (req, res) => {
const id = Number(req.params.id);
await sql.query('DELETE FROM correspondences WHERE id=?', [id]);
res.json({ ok: 1 });
}
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 });
}
);
export default r;
export default r;

View File

@@ -1,3 +1,12 @@
// 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';

View File

@@ -1,31 +1,63 @@
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import { buildScopeWhere, ownerResolvers } from '../utils/scope.js';
import PERM from '../config/permissions.js';
// FILE: src/routes/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
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');
const OWN = ownerResolvers(sql, "drawings", "id");
// LIST
r.get('/',
requirePerm('drawing.read', { scope: 'global' }),
r.get(
"/",
requirePerm("drawing.read", { scope: "global" }),
async (req, res) => {
const { project_id, org_id, code, q, limit = 50, offset = 0 } = req.query;
const base = buildScopeWhere(req.principal, {
tableAlias: 'd', orgColumn: 'd.org_id', projectColumn: 'd.project_id',
permCode: 'drawing.read', preferProject: true,
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 (org_id) { extra.push('d.org_id = :org_id'); params.org_id = Number(org_id); }
if (code) { extra.push('d.dwg_code = :code'); params.code = code; }
if (q) { extra.push('(d.dwg_no LIKE :q OR d.title LIKE :q)'); params.q = `%${q}%`; }
const params = {
...base.params,
limit: Number(limit),
offset: Number(offset),
};
if (project_id) {
extra.push("d.project_id = :project_id");
params.project_id = Number(project_id);
}
if (org_id) {
extra.push("d.org_id = :org_id");
params.org_id = Number(org_id);
}
if (code) {
extra.push("d.dwg_code = :code");
params.code = code;
}
if (q) {
extra.push("(d.dwg_no LIKE :q OR d.title LIKE :q)");
params.q = `%${q}%`;
}
const where = [base.where, ...extra].filter(Boolean).join(' AND ');
const where = [base.where, ...extra].filter(Boolean).join(" AND ");
const [rows] = await sql.query(
`SELECT d.* FROM drawings d WHERE ${where}
@@ -37,19 +69,24 @@ r.get('/',
);
// GET
r.get('/:id',
requirePerm('drawing.read', { scope: 'org', getOrgId: OWN.getOrgIdById }),
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' });
const [[row]] = await sql.query("SELECT * FROM drawings WHERE id=?", [id]);
if (!row) return res.status(404).json({ error: "Not found" });
res.json(row);
}
);
// CREATE
r.post('/',
requirePerm('drawing.create', { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
r.post(
"/",
requirePerm("drawing.create", {
scope: "org",
getOrgId: async (req) => req.body?.org_id ?? null,
}),
async (req, res) => {
const { org_id, project_id, dwg_no, dwg_code, title } = req.body;
const [rs] = await sql.query(
@@ -62,22 +99,24 @@ r.post('/',
);
// UPDATE
r.put('/:id',
requirePerm('drawing.update', { scope: 'org', getOrgId: OWN.getOrgIdById }),
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]);
await sql.query("UPDATE drawings SET title=? WHERE id=?", [title, id]);
res.json({ ok: 1 });
}
);
// DELETE
r.delete('/:id',
requirePerm('drawing.delete', { scope: 'org', getOrgId: OWN.getOrgIdById }),
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]);
await sql.query("DELETE FROM drawings WHERE id=?", [id]);
res.json({ ok: 1 });
}
);

View File

@@ -1,90 +1,150 @@
import { Router } from 'express';
import fs from 'fs';
import path from 'path';
import jwt from 'jsonwebtoken';
import { requireAuth } from '../middleware/auth.js';
import { enrichPermissions } from '../middleware/permissions.js';
import { requireRole } from '../middleware/rbac.js';
import { requirePerm } from '../middleware/permGuard.js';
import { sequelize } from '../db/sequelize.js';
import FileModel from '../db/models/FileObject.js';
import { config } from '../config.js';
// FILE: 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
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";
const r = Router();
const Files = FileModel(sequelize);
async function projectForFile(rec) {
const mod = rec.module; const refId = rec.ref_id;
const mod = rec.module;
const refId = rec.ref_id;
switch (mod) {
case 'rfa': { const M = (await import('../db/models/RFA.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; }
case 'correspondence': { const M = (await import('../db/models/Correspondence.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; }
case 'drawing': { const M = (await import('../db/models/Drawing.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; }
case 'document': { const M = (await import('../db/models/Document.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; }
case 'transmittal': { const M = (await import('../db/models/Transmittal.js')).default(sequelize); const row = await M.findByPk(refId); return row?.project_id||null; }
default: return null;
case "rfa": {
const M = (await import("../db/models/RFA.js")).default(sequelize);
const row = await M.findByPk(refId);
return row?.project_id || null;
}
case "correspondence": {
const M = (await import("../db/models/Correspondence.js")).default(
sequelize
);
const row = await M.findByPk(refId);
return row?.project_id || null;
}
case "drawing": {
const M = (await import("../db/models/Drawing.js")).default(sequelize);
const row = await M.findByPk(refId);
return row?.project_id || null;
}
case "document": {
const M = (await import("../db/models/Document.js")).default(sequelize);
const row = await M.findByPk(refId);
return row?.project_id || null;
}
case "transmittal": {
const M = (await import("../db/models/Transmittal.js")).default(
sequelize
);
const row = await M.findByPk(refId);
return row?.project_id || null;
}
default:
return null;
}
}
// HEAD meta only
r.head('/files/:file_id', requireAuth, async (req, res) => {
r.head("/files/:file_id", requireAuth, async (req, res) => {
const rec = await Files.findByPk(Number(req.params.file_id));
if (!rec) return res.status(404).end();
res.setHeader('Content-Type', rec.mime || 'application/octet-stream');
res.setHeader('Content-Length', String(rec.size || 0));
res.setHeader("Content-Type", rec.mime || "application/octet-stream");
res.setHeader("Content-Length", String(rec.size || 0));
res.status(200).end();
});
// delete (soft delete is recommended; here we do physical delete + record delete)
r.delete('/files/:file_id', requireAuth, enrichPermissions(), requirePerm('file:delete'), async (req, res) => {
const rec = await Files.findByPk(Number(req.params.file_id));
if (!rec) return res.status(404).json({ error: 'Not found' });
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' });
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 });
}
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' });
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 });
}
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) => {
r.post("/files/:file_id/refresh-url", requireAuth, async (req, res) => {
const rec = await Files.findByPk(Number(req.params.file_id));
if (!rec) return res.status(404).json({ error: 'Not found' });
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');
const isAdmin = roles.includes("Admin");
if (!isAdmin) {
const { getUserProjectIds } = await import('../middleware/abac.js');
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 (!memberProjects.includes(pid))
return res.status(403).json({ error: "Forbidden: not a project member" });
}
const expSec = Number(process.env.FILE_URL_EXPIRES || 600);
const token = jwt.sign({ file_id: rec.file_id }, config.JWT.SECRET, { expiresIn: expSec });
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}`;
res.json({ download_url, expires_in: expSec });
});

View File

@@ -1,13 +1,18 @@
import { Router } from 'express';
import { sequelize } from '../db/sequelize.js';
// FILE: src/routes/health.js
// Health check route
// - GET /health to check server and database status
// - Requires appropriate permissions via requirePerm middleware
import { Router } from "express";
import { sequelize } from "../db/sequelize.js";
const r = Router();
r.get('/health', async (_req, res) => {
r.get("/health", async (_req, res) => {
try {
await sequelize.query('SELECT 1 AS ok');
res.status(200).json({ ok: true, db: 'up' });
await sequelize.query("SELECT 1 AS ok");
res.status(200).json({ ok: true, db: "up" });
} catch (e) {
res.status(500).json({ ok: false, db: 'down', error: String(e) });
res.status(500).json({ ok: false, db: "down", error: String(e) });
}
});
export default r;

View File

@@ -1,9 +1,17 @@
// src/routes/lookup.js (ESM)
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import { buildScopeWhere } from '../utils/scope.js';
import PERM from '../config/permissions.js';
// FILE: 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
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();
@@ -13,34 +21,47 @@ const r = Router();
function parsePick(qs) {
if (!qs) return null;
return String(qs)
.split(',')
.map(s => s.trim().toLowerCase())
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
}
// GET /api/lookup?pick=org,project,category,subcategory,volume,permission
r.get('/',
r.get(
"/",
// ต้องเป็นผู้ใช้ที่ยืนยันตัวตนแล้ว (middleware authJwt+loadPrincipal อยู่ใน /api)
// ไม่ require permission เดียว เพราะเราจะเช็คทีละชุดด้านล่าง
async (req, res) => {
const pick = new Set(parsePick(req.query.pick) || [
'org', 'project', 'category', 'subcategory', 'volume', 'permission'
]);
const pick = new Set(
parsePick(req.query.pick) || [
"org",
"project",
"category",
"subcategory",
"volume",
"permission",
]
);
const result = {};
// 1) Organizations (scoped list) — require organization.read
if (pick.has('org')) {
if (pick.has("org")) {
// มีสิทธิ์ถึงจะดึง
const canOrg = req.principal.isSuperAdmin || req.principal.perms.has(PERM.organization.read);
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',
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);
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 = [];
@@ -48,13 +69,15 @@ r.get('/',
}
// 2) Projects (scoped list) — require project.read
if (pick.has('project')) {
const canPrj = req.principal.isSuperAdmin || req.principal.perms.has(PERM.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',
tableAlias: "p",
orgColumn: "p.org_id",
projectColumn: "p.project_id",
permCode: PERM.project.read,
preferProject: true,
});
@@ -70,10 +93,14 @@ r.get('/',
}
// 3) Categories (global master) — require category.read
if (pick.has('category')) {
const can = req.principal.isSuperAdmin || req.principal.perms.has(PERM.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');
const [rows] = await sql.query(
"SELECT cat_id, cat_code, cat_name FROM categories ORDER BY cat_name"
);
result.categories = rows;
} else {
result.categories = [];
@@ -81,10 +108,14 @@ r.get('/',
}
// 4) Subcategories (global master) — require category.read
if (pick.has('subcategory')) {
const can = req.principal.isSuperAdmin || req.principal.perms.has(PERM.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');
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 = [];
@@ -92,10 +123,13 @@ r.get('/',
}
// 5) Volumes (global master) — require volume.read
if (pick.has('volume')) {
const can = req.principal.isSuperAdmin || req.principal.perms.has(PERM.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');
const [rows] = await sql.query(
"SELECT volume_id, volume_code, volume_name FROM volumes ORDER BY volume_code"
);
result.volumes = rows;
} else {
result.volumes = [];
@@ -103,10 +137,14 @@ r.get('/',
}
// 6) Permissions (global master) — require permission.read
if (pick.has('permission')) {
const can = req.principal.isSuperAdmin || req.principal.perms.has(PERM.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');
const [rows] = await sql.query(
"SELECT permission_id, permission_code, description FROM permissions ORDER BY permission_code"
);
result.permissions = rows;
} else {
result.permissions = [];

View File

@@ -1,15 +1,23 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth.js';
import { enrichPermissions } from '../middleware/permissions.js';
import { requireRole } from '../middleware/rbac.js';
import { requirePerm } from '../middleware/permGuard.js';
import { sequelize } from '../db/sequelize.js';
import RfaModel from '../db/models/RFA.js';
import DrawingModel from '../db/models/Drawing.js';
import RfaDrawMapModel from '../db/models/RfaDrawingMap.js';
import CorrModel from '../db/models/Correspondence.js';
import DocModel from '../db/models/Document.js';
import CorrDocMapModel from '../db/models/CorrDocumentMap.js';
// FILE: 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
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";
const r = Router();
const RFA = RfaModel(sequelize);
@@ -22,62 +30,121 @@ 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 (!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 { 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; }
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 (!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 { 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; }
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) } });
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 });
});
r.post(
"/maps/rfa/:rfa_id/drawings/:drawing_id",
requireAuth,
enrichPermissions(),
requirePerm("rfa:update"),
async (req, res) => {
if (!(await ensureRfaMembership(req, res))) return;
const { rfa_id, drawing_id } = {
rfa_id: Number(req.params.rfa_id),
drawing_id: Number(req.params.drawing_id),
};
await RfaDraw.create({ rfa_id, drawing_id });
res.status(201).json({ ok: true });
}
);
r.delete(
"/maps/rfa/:rfa_id/drawings/:drawing_id",
requireAuth,
enrichPermissions(),
requirePerm("rfa:update"),
async (req, res) => {
if (!(await ensureRfaMembership(req, res))) return;
const { rfa_id, drawing_id } = {
rfa_id: Number(req.params.rfa_id),
drawing_id: Number(req.params.drawing_id),
};
const count = await RfaDraw.destroy({ where: { rfa_id, drawing_id } });
res.json({ ok: count > 0 });
}
);
// Correspondence <-> Document
r.get('/maps/correspondence/:corr_id/documents', requireAuth, async (req, res) => {
const rows = await CorrDoc.findAll({ where: { correspondence_id: Number(req.params.corr_id) } });
res.json(rows);
});
r.post('/maps/correspondence/:corr_id/documents/:doc_id', requireAuth, enrichPermissions(), requirePerm('correspondence:update'), async (req, res) => {
if (!(await ensureCorrMembership(req, res))) return;
const { corr_id, doc_id } = { corr_id: Number(req.params.corr_id), doc_id: Number(req.params.doc_id) };
await CorrDoc.create({ correspondence_id: corr_id, document_id: doc_id });
res.status(201).json({ ok: true });
});
r.delete('/maps/correspondence/:corr_id/documents/:doc_id', requireAuth, enrichPermissions(), requirePerm('correspondence:update'), async (req, res) => {
if (!(await ensureCorrMembership(req, res))) return;
const { corr_id, doc_id } = { corr_id: Number(req.params.corr_id), doc_id: Number(req.params.doc_id) };
const count = await CorrDoc.destroy({ where: { correspondence_id: corr_id, document_id: doc_id } });
res.json({ ok: count > 0 });
});
r.get(
"/maps/correspondence/:corr_id/documents",
requireAuth,
async (req, res) => {
const rows = await CorrDoc.findAll({
where: { correspondence_id: Number(req.params.corr_id) },
});
res.json(rows);
}
);
r.post(
"/maps/correspondence/:corr_id/documents/:doc_id",
requireAuth,
enrichPermissions(),
requirePerm("correspondence:update"),
async (req, res) => {
if (!(await ensureCorrMembership(req, res))) return;
const { corr_id, doc_id } = {
corr_id: Number(req.params.corr_id),
doc_id: Number(req.params.doc_id),
};
await CorrDoc.create({ correspondence_id: corr_id, document_id: doc_id });
res.status(201).json({ ok: true });
}
);
r.delete(
"/maps/correspondence/:corr_id/documents/:doc_id",
requireAuth,
enrichPermissions(),
requirePerm("correspondence:update"),
async (req, res) => {
if (!(await ensureCorrMembership(req, res))) return;
const { corr_id, doc_id } = {
corr_id: Number(req.params.corr_id),
doc_id: Number(req.params.doc_id),
};
const count = await CorrDoc.destroy({
where: { correspondence_id: corr_id, document_id: doc_id },
});
res.json({ ok: count > 0 });
}
);
export default r;

View File

@@ -1,16 +1,32 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth.js';
import { sequelize } from '../db/sequelize.js';
import FileModel from '../db/models/FileObject.js';
// 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
import { Router } from "express";
import { requireAuth } from "../middleware/auth.js";
import { sequelize } from "../db/sequelize.js";
import FileModel from "../db/models/FileObject.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']] });
return Files.findAll({
where: { module: mod, ref_id },
order: [["created_at", "DESC"]],
});
}
for (const mod of ['rfa','correspondence','drawing','document','transmittal']) {
for (const mod of [
"rfa",
"correspondence",
"drawing",
"document",
"transmittal",
]) {
r.get(`/${mod}s/:id/files`, requireAuth, async (req, res) => {
const items = await listBy(mod, Number(req.params.id));
res.json(items);

View File

@@ -1,36 +1,71 @@
// src/routes/map.js
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import { buildScopeWhere, ownerResolvers } from '../utils/scope.js';
import PERM from '../config/permissions.js';
// FILE: 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
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');
const OWN = ownerResolvers(sql, "entity_maps", "id");
// LIST
r.get('/',
requirePerm(PERM.map.read, { scope: 'global' }),
r.get(
"/",
requirePerm(PERM.map.read, { scope: "global" }),
async (req, res) => {
const { project_id, org_id, module, src_type, dst_type, limit = 100, offset = 0 } = req.query;
const {
project_id,
org_id,
module,
src_type,
dst_type,
limit = 100,
offset = 0,
} = req.query;
const base = buildScopeWhere(req.principal, {
tableAlias: 'm',
orgColumn: 'm.org_id',
projectColumn: 'm.project_id',
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 (org_id) { extra.push('m.org_id = :org_id'); params.org_id = Number(org_id); }
if (module) { extra.push('m.module = :module'); params.module = module; }
if (src_type) { extra.push('m.src_type = :src_type'); params.src_type = src_type; }
if (dst_type) { extra.push('m.dst_type = :dst_type'); params.dst_type = dst_type; }
const params = {
...base.params,
limit: Number(limit),
offset: Number(offset),
};
if (project_id) {
extra.push("m.project_id = :project_id");
params.project_id = Number(project_id);
}
if (org_id) {
extra.push("m.org_id = :org_id");
params.org_id = Number(org_id);
}
if (module) {
extra.push("m.module = :module");
params.module = module;
}
if (src_type) {
extra.push("m.src_type = :src_type");
params.src_type = src_type;
}
if (dst_type) {
extra.push("m.dst_type = :dst_type");
params.dst_type = dst_type;
}
const where = [base.where, ...extra].filter(Boolean).join(' AND ');
const where = [base.where, ...extra].filter(Boolean).join(" AND ");
const [rows] = await sql.query(
`SELECT m.* FROM entity_maps m
WHERE ${where}
@@ -42,25 +77,49 @@ r.get('/',
);
// CREATE
r.post('/',
requirePerm(PERM.map.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
r.post(
"/",
requirePerm(PERM.map.create, {
scope: "org",
getOrgId: async (req) => req.body?.org_id ?? null,
}),
async (req, res) => {
const { org_id, project_id, module, src_type, src_id, dst_type, dst_id, remark } = req.body;
const {
org_id,
project_id,
module,
src_type,
src_id,
dst_type,
dst_id,
remark,
} = req.body;
const [rs] = await sql.query(
`INSERT INTO entity_maps (org_id, project_id, module, src_type, src_id, dst_type, dst_id, remark, created_by)
VALUES (?,?,?,?,?,?,?,?,?)`,
[org_id, project_id, module, src_type, Number(src_id), dst_type, Number(dst_id), remark ?? null, req.principal.userId]
[
org_id,
project_id,
module,
src_type,
Number(src_id),
dst_type,
Number(dst_id),
remark ?? null,
req.principal.userId,
]
);
res.json({ id: rs.insertId });
}
);
// DELETE (by id)
r.delete('/:id',
requirePerm(PERM.map.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
r.delete(
"/:id",
requirePerm(PERM.map.delete, { scope: "org", getOrgId: OWN.getOrgIdById }),
async (req, res) => {
const id = Number(req.params.id);
await sql.query('DELETE FROM entity_maps WHERE id=?', [id]);
await sql.query("DELETE FROM entity_maps WHERE id=?", [id]);
res.json({ ok: 1 });
}
);

View File

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

View File

@@ -1,18 +1,32 @@
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import { buildScopeWhere } from '../utils/scope.js';
import PERM from '../config/permissions.js';
// FILE: 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
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' }),
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',
tableAlias: "o",
orgColumn: "o.org_id",
projectColumn: "NULL",
permCode: "organization.read",
});
const [rows] = await sql.query(
@@ -24,15 +38,19 @@ r.get('/',
);
// GET by id
r.get('/:id',
requirePerm('organization.read', {
scope: 'org',
getOrgId: async req => Number(req.params.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' });
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);
}
);

View File

@@ -1,14 +1,25 @@
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import PERM from '../config/permissions.js';
// FILE: src/routes/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
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' }),
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');
const [rows] = await sql.query(
"SELECT permission_id, permission_code, description FROM permissions ORDER BY permission_code"
);
res.json(rows);
}
);

View File

@@ -1,18 +1,34 @@
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import { buildScopeWhere } from '../utils/scope.js';
import PERM from '../config/permissions.js';
// FILE: 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
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 ของผู้ใช้
r.get('/',
requirePerm('project.read', { scope: 'global' }),
r.get(
"/",
requirePerm("project.read", { scope: "global" }),
async (req, res) => {
const { where, params } = buildScopeWhere(req.principal, {
tableAlias: 'p', orgColumn: 'p.org_id', projectColumn: 'p.project_id',
permCode: 'project.read', preferProject: true,
tableAlias: "p",
orgColumn: "p.org_id",
projectColumn: "p.project_id",
permCode: "project.read",
preferProject: true,
});
const [rows] = await sql.query(
`SELECT p.* FROM projects p WHERE ${where}`,
@@ -23,29 +39,34 @@ r.get('/',
);
// GET
r.get('/:id',
requirePerm('project.read', {
scope: 'project',
getProjectId: async req => Number(req.params.id),
r.get(
"/:id",
requirePerm("project.read", {
scope: "project",
getProjectId: async (req) => Number(req.params.id),
}),
async (req, res) => {
const id = Number(req.params.id);
const [[row]] = await sql.query('SELECT * FROM projects WHERE project_id=?', [id]);
if (!row) return res.status(404).json({ error: 'Not found' });
const [[row]] = await sql.query(
"SELECT * FROM projects WHERE project_id=?",
[id]
);
if (!row) return res.status(404).json({ error: "Not found" });
res.json(row);
}
);
// CREATE
r.post('/',
requirePerm('project.create', {
scope: 'org',
getOrgId: async req => req.body?.org_id ?? null,
r.post(
"/",
requirePerm("project.create", {
scope: "org",
getOrgId: async (req) => req.body?.org_id ?? null,
}),
async (req, res) => {
const { org_id, project_code, project_name } = req.body;
const [rs] = await sql.query(
'INSERT INTO projects (org_id, project_code, project_name) VALUES (?,?,?)',
"INSERT INTO projects (org_id, project_code, project_name) VALUES (?,?,?)",
[org_id, project_code, project_name]
);
res.json({ project_id: rs.insertId });
@@ -53,28 +74,33 @@ r.post('/',
);
// UPDATE
r.put('/:id',
requirePerm('project.update', {
scope: 'project',
getProjectId: async req => Number(req.params.id),
r.put(
"/:id",
requirePerm("project.update", {
scope: "project",
getProjectId: async (req) => Number(req.params.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]);
await sql.query("UPDATE projects SET project_name=? WHERE project_id=?", [
project_name,
id,
]);
res.json({ ok: 1 });
}
);
// DELETE
r.delete('/:id',
requirePerm('project.delete', {
scope: 'project',
getProjectId: async req => Number(req.params.id),
r.delete(
"/:id",
requirePerm("project.delete", {
scope: "project",
getProjectId: async (req) => Number(req.params.id),
}),
async (req, res) => {
const id = Number(req.params.id);
await sql.query('DELETE FROM projects WHERE project_id=?', [id]);
await sql.query("DELETE FROM projects WHERE project_id=?", [id]);
res.json({ ok: 1 });
}
);

View File

@@ -1,101 +1,136 @@
// src/routes/rbac_admin.js (ESM)
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import PERM from '../config/permissions.js';
// FILE: 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
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' }),
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');
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' }),
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');
const [rows] = await sql.query(
"SELECT permission_id, permission_code, description FROM permissions ORDER BY permission_code"
);
res.json(rows);
}
);
/** LIST: role→permissions */
r.get('/roles/:role_id/permissions',
requirePerm(PERM.rbac_admin.read, { scope: 'global' }),
r.get(
"/roles/:role_id/permissions",
requirePerm(PERM.rbac_admin.read, { scope: "global" }),
async (req, res) => {
const role_id = Number(req.params.role_id);
const [rows] = await sql.query(
`SELECT p.permission_id, p.permission_code, p.description
FROM role_permissions rp
JOIN permissions p ON p.permission_id = rp.permission_id
WHERE rp.role_id=? ORDER BY p.permission_code`, [role_id]);
WHERE rp.role_id=? ORDER BY p.permission_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' }),
r.post(
"/roles/:role_id/permissions",
requirePerm(PERM.rbac_admin.grant_perm, { scope: "global" }),
async (req, res) => {
const role_id = Number(req.params.role_id);
const { permission_id } = req.body || {};
await sql.query('INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?,?)',
[role_id, Number(permission_id)]);
res.json({ ok: 1 });
}
);
r.delete('/roles/:role_id/permissions/:permission_id',
requirePerm(PERM.rbac_admin.grant_perm, { scope: 'global' }),
async (req, res) => {
const role_id = Number(req.params.role_id);
const permission_id = Number(req.params.permission_id);
await sql.query('DELETE FROM role_permissions WHERE role_id=? AND permission_id=?', [role_id, permission_id]);
res.json({ ok: 1 });
}
);
/** LIST: user→roles(+scope) */
r.get('/users/:user_id/roles',
requirePerm(PERM.rbac_admin.read, { scope: 'global' }),
async (req, res) => {
const user_id = Number(req.params.user_id);
const [rows] = await sql.query(
`SELECT ur.user_id, ur.role_id, r.role_code, r.role_name, ur.org_id, ur.project_id
FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id
WHERE ur.user_id=? ORDER BY r.role_code`, [user_id]);
res.json(rows);
}
);
/** MAP: user↔role(+scope) (assign / revoke) */
r.post('/users/:user_id/roles',
requirePerm(PERM.rbac_admin.assign_role, { scope: 'global' }),
async (req, res) => {
const user_id = Number(req.params.user_id);
const { role_id, org_id = null, project_id = null } = req.body || {};
await sql.query(
'INSERT INTO user_roles (user_id, role_id, org_id, project_id) VALUES (?,?,?,?)',
[user_id, Number(role_id), org_id ? Number(org_id) : null, project_id ? Number(project_id) : null]
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?,?)",
[role_id, Number(permission_id)]
);
res.json({ ok: 1 });
}
);
r.delete('/users/:user_id/roles',
requirePerm(PERM.rbac_admin.assign_role, { scope: 'global' }),
r.delete(
"/roles/:role_id/permissions/:permission_id",
requirePerm(PERM.rbac_admin.grant_perm, { scope: "global" }),
async (req, res) => {
const role_id = Number(req.params.role_id);
const permission_id = Number(req.params.permission_id);
await sql.query(
"DELETE FROM role_permissions WHERE role_id=? AND permission_id=?",
[role_id, permission_id]
);
res.json({ ok: 1 });
}
);
/** LIST: user→roles(+scope) */
r.get(
"/users/:user_id/roles",
requirePerm(PERM.rbac_admin.read, { scope: "global" }),
async (req, res) => {
const user_id = Number(req.params.user_id);
const [rows] = await sql.query(
`SELECT ur.user_id, ur.role_id, r.role_code, r.role_name, ur.org_id, ur.project_id
FROM user_roles ur JOIN roles r ON r.role_id = ur.role_id
WHERE ur.user_id=? ORDER BY r.role_code`,
[user_id]
);
res.json(rows);
}
);
/** MAP: user↔role(+scope) (assign / revoke) */
r.post(
"/users/:user_id/roles",
requirePerm(PERM.rbac_admin.assign_role, { scope: "global" }),
async (req, res) => {
const user_id = Number(req.params.user_id);
const { role_id, org_id = null, project_id = null } = req.body || {};
await sql.query(
'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=?')),
"INSERT INTO user_roles (user_id, role_id, org_id, project_id) VALUES (?,?,?,?)",
[
user_id,
Number(role_id),
org_id ? Number(org_id) : null,
project_id ? Number(project_id) : null,
]
);
res.json({ ok: 1 });
}
);
r.delete(
"/users/:user_id/roles",
requirePerm(PERM.rbac_admin.assign_role, { scope: "global" }),
async (req, res) => {
const user_id = Number(req.params.user_id);
const { role_id, org_id = null, project_id = null } = req.body || {};
await sql.query(
"DELETE FROM user_roles WHERE user_id=? AND role_id=? AND <=> org_id ? AND <=> project_id ?"
.replace("<=> org_id ?", org_id === null ? "org_id IS ?" : "org_id=?")
.replace(
"<=> project_id ?",
project_id === null ? "project_id IS ?" : "project_id=?"
),
[user_id, Number(role_id), org_id, project_id]
);
res.json({ ok: 1 });

View File

@@ -1,34 +1,74 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth.js';
import { requirePermission } from '../middleware/perm.js';
import { callProc } from '../db/index.js';
// 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
import { Router } from "express";
import { requireAuth } from "../middleware/auth.js";
import { requirePermission } from "../middleware/perm.js";
import { callProc } from "../db/index.js";
const router = Router();
router.post('/create',
router.post(
"/create",
requireAuth,
requirePermission(['RFA_CREATE'], { projectRequired: true }),
requirePermission(["RFA_CREATE"], { projectRequired: true }),
async (req, res, next) => {
try {
const { project_id, cor_status_id, cor_no, title, originator_id, recipient_id, keywords = null, pdf_path = null, item_doc_ids = [] } = req.body || {};
const {
project_id,
cor_status_id,
cor_no,
title,
originator_id,
recipient_id,
keywords = null,
pdf_path = null,
item_doc_ids = [],
} = req.body || {};
const json = JSON.stringify(item_doc_ids.map(Number));
await callProc('sp_rfa_create_with_items', [
req.user.user_id, project_id, cor_status_id, cor_no, title, originator_id, recipient_id, keywords, pdf_path, json, null
await callProc("sp_rfa_create_with_items", [
req.user.user_id,
project_id,
cor_status_id,
cor_no,
title,
originator_id,
recipient_id,
keywords,
pdf_path,
json,
null,
]);
res.status(201).json({ ok: true });
} catch (e) { next(e); }
} catch (e) {
next(e);
}
}
);
router.post('/update-status',
router.post(
"/update-status",
requireAuth,
requirePermission(['RFA_STATUS_UPDATE'], { projectRequired: true }),
requirePermission(["RFA_STATUS_UPDATE"], { projectRequired: true }),
async (req, res, next) => {
try {
const { rfa_corr_id, status_id, set_issue = 0 } = req.body || {};
await callProc('sp_rfa_update_status', [req.user.user_id, rfa_corr_id, status_id, set_issue ? 1 : 0]);
await callProc("sp_rfa_update_status", [
req.user.user_id,
rfa_corr_id,
status_id,
set_issue ? 1 : 0,
]);
res.json({ ok: true });
} catch (e) { next(e); }
} catch (e) {
next(e);
}
}
);

View File

@@ -1,28 +1,43 @@
// backend/src/routes/rfas.js (merged)
// 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
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import { buildScopeWhere, ownerResolvers } from '../utils/scope.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, 'rfas', 'id');
const OWN = ownerResolvers(sql, "rfas", "id");
/* ----------------------------- Utilities ----------------------------- */
// Allow-list สำหรับการ sort ป้องกัน SQL injection
const ALLOWED_SORT = new Map([
['updated_at', 'updated_at'],
['due_date', 'due_date'],
['created_at', 'created_at'],
['id', 'id']
["updated_at", "updated_at"],
["due_date", "due_date"],
["created_at", "created_at"],
["id", "id"],
]);
function parseSort(sort = 'updated_at:desc') {
const [colRaw, dirRaw] = String(sort).split(':');
const col = ALLOWED_SORT.get(colRaw) || 'updated_at';
const dir = (dirRaw || 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
function parseSort(sort = "updated_at:desc") {
const [colRaw, dirRaw] = String(sort).split(":");
const col = ALLOWED_SORT.get(colRaw) || "updated_at";
const dir = (dirRaw || "desc").toLowerCase() === "asc" ? "ASC" : "DESC";
return `\`${col}\` ${dir}`;
}
@@ -36,15 +51,29 @@ function parsePaging({ page = 1, pageSize = 20 }) {
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')");
if (project_id) {
parts.push("r.project_id = :project_id");
params.project_id = Number(project_id);
}
return { where: parts.join(' AND '), params };
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 --------------------------------
@@ -52,63 +81,71 @@ function buildExtraFilters({ q, status, overdue, project_id, org_id }) {
- คง 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;
const orderBy = parseSort(sort);
const { limit, offset, page: p, pageSize: ps } = parsePaging({ page, pageSize });
r.get("/", requirePerm("rfa.read", { scope: "global" }), async (req, res) => {
try {
const { q, status, overdue, sort, page, pageSize, project_id, org_id } =
req.query;
const orderBy = parseSort(sort);
const {
limit,
offset,
page: p,
pageSize: ps,
} = parsePaging({ 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,
});
// 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,
});
// extra filters
const extra = buildExtraFilters({ q, status, overdue, project_id, org_id });
// extra filters
const extra = buildExtraFilters({ q, status, overdue, project_id, org_id });
// รวม where
const where = [base.where, extra.where].filter(Boolean).join(' AND ') || '1=1';
const params = { ...base.params, ...extra.params, limit, offset };
// รวม where
const where =
[base.where, extra.where].filter(Boolean).join(" AND ") || "1=1";
const params = { ...base.params, ...extra.params, limit, offset };
// total
const [[{ cnt: total }]] = await sql.query(
`SELECT COUNT(*) AS cnt FROM rfas r WHERE ${where}`,
params
);
// total
const [[{ cnt: total }]] = await sql.query(
`SELECT COUNT(*) AS cnt FROM rfas r WHERE ${where}`,
params
);
// rows
const [rows] = await sql.query(
`SELECT r.id, r.code, r.rfa_no, r.title, r.status, r.discipline, r.due_date, r.owner_id, r.updated_at, r.project_id, r.org_id
// 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
);
params
);
res.json({ data: rows, total: Number(total || 0), page: p, pageSize: ps });
} catch (e) {
res.status(500).json({ error: e.message || 'rfas/list failed' });
}
res.json({ data: rows, total: Number(total || 0), page: p, pageSize: ps });
} catch (e) {
res.status(500).json({ error: e.message || "rfas/list failed" });
}
);
});
/* ------------------------------- GET ONE ------------------------------
// ยึดรูปแบบตรวจสิทธิ์จาก rfas.js
------------------------------------------------------------------------*/
r.get('/:id',
requirePerm('rfa.read', { scope: 'org', getOrgId: OWN.getOrgIdById }),
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' });
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' });
res.status(500).json({ error: e.message || "rfas/detail failed" });
}
}
);
@@ -117,14 +154,19 @@ r.get('/:id',
// ยึดรูปแบบฟิลด์ของ rfas.js (org_id, project_id, rfa_no, title, status)
// เพิ่ม validation เบื้องต้น (title required)
------------------------------------------------------------------------*/
r.post('/',
requirePerm('rfa.create', { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
r.post(
"/",
requirePerm("rfa.create", {
scope: "org",
getOrgId: async (req) => req.body?.org_id ?? null,
}),
async (req, res) => {
try {
const { org_id, project_id, rfa_no, title, status } = req.body || {};
if (!title?.trim()) return res.status(400).json({ error: 'title is required' });
if (!title?.trim())
return res.status(400).json({ error: "title is required" });
const st = String(status || '').trim() || 'draft';
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())`,
@@ -132,7 +174,7 @@ r.post('/',
);
res.status(201).json({ id: rs.insertId });
} catch (e) {
res.status(500).json({ error: e.message || 'rfas/create failed' });
res.status(500).json({ error: e.message || "rfas/create failed" });
}
}
);
@@ -141,64 +183,86 @@ r.post('/',
// PUT: รูปแบบเดิมของ rfas.js (อัปเดต title, status)
// PATCH: แบบยืดหยุ่น (บางฟิลด์) ผสานแนวทางจาก rfas-1.js
------------------------------------------------------------------------*/
r.put('/:id',
requirePerm('rfa.update', { scope: 'org', getOrgId: OWN.getOrgIdById }),
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]);
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' });
res.status(500).json({ error: e.message || "rfas/update failed" });
}
}
);
// PATCH แบบ partial fields
r.patch('/:id',
requirePerm('rfa.update', { scope: 'org', getOrgId: OWN.getOrgIdById }),
r.patch(
"/:id",
requirePerm("rfa.update", { scope: "org", getOrgId: OWN.getOrgIdById }),
async (req, res) => {
try {
const id = Number(req.params.id);
const allowed = ['code', 'rfa_no', 'title', 'discipline', 'due_date', 'description', 'status', 'owner_id'];
const allowed = [
"code",
"rfa_no",
"title",
"discipline",
"due_date",
"description",
"status",
"owner_id",
];
const patch = {};
for (const k of allowed) if (k in req.body) patch[k] = req.body[k];
if (Object.keys(patch).length === 0) {
return res.status(400).json({ error: 'no fields to update' });
return res.status(400).json({ error: "no fields to update" });
}
if ('status' in patch) {
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 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}`);
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`,
`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' });
res.status(500).json({ error: e.message || "rfas/patch failed" });
}
}
);
/* ------------------------------- DELETE ------------------------------- */
r.delete('/:id',
requirePerm('rfa.delete', { scope: 'org', getOrgId: OWN.getOrgIdById }),
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]);
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' });
res.status(500).json({ error: e.message || "rfas/delete failed" });
}
}
);

View File

@@ -1,48 +1,93 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth.js';
import { enrichPermissions } from '../middleware/permissions.js';
import { requireRole } from '../middleware/rbac.js';
import { requirePerm } from '../middleware/permGuard.js';
import { sequelize } from '../db/sequelize.js';
import SubCatModel from '../db/models/SubCategory.js';
// FILE: 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
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";
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;
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']] });
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('/sub_categories', requireAuth, enrichPermissions(), requirePerm('lookup:edit'), async (req, res) => {
const { project_id, sub_cat_name, parent_cat_id, code } = req.body || {};
if (!project_id || !sub_cat_name) return res.status(400).json({ error: 'project_id and sub_cat_name required' });
const created = await SubCat.create({ project_id, sub_cat_name, parent_cat_id, code });
res.status(201).json({ sub_cat_id: created.sub_cat_id });
});
r.post(
"/sub_categories",
requireAuth,
enrichPermissions(),
requirePerm("lookup:edit"),
async (req, res) => {
const { project_id, sub_cat_name, parent_cat_id, code } = req.body || {};
if (!project_id || !sub_cat_name)
return res
.status(400)
.json({ error: "project_id and sub_cat_name required" });
const created = await SubCat.create({
project_id,
sub_cat_name,
parent_cat_id,
code,
});
res.status(201).json({ sub_cat_id: created.sub_cat_id });
}
);
r.patch('/sub_categories/:id', requireAuth, enrichPermissions(), requirePerm('lookup:edit'), async (req, res) => {
const row = await SubCat.findByPk(Number(req.params.id));
if (!row) return res.status(404).json({ error: 'Not found' });
const { sub_cat_name, parent_cat_id, code } = req.body || {};
if (sub_cat_name !== undefined) row.sub_cat_name = sub_cat_name;
if (parent_cat_id !== undefined) row.parent_cat_id = parent_cat_id;
if (code !== undefined) row.code = code;
await row.save();
res.json({ ok: true });
});
r.patch(
"/sub_categories/:id",
requireAuth,
enrichPermissions(),
requirePerm("lookup:edit"),
async (req, res) => {
const row = await SubCat.findByPk(Number(req.params.id));
if (!row) return res.status(404).json({ error: "Not found" });
const { sub_cat_name, parent_cat_id, code } = req.body || {};
if (sub_cat_name !== undefined) row.sub_cat_name = sub_cat_name;
if (parent_cat_id !== undefined) row.parent_cat_id = parent_cat_id;
if (code !== undefined) row.code = code;
await row.save();
res.json({ ok: true });
}
);
r.delete('/sub_categories/:id', requireAuth, enrichPermissions(), requirePerm('lookup:edit'), async (req, res) => {
const row = await SubCat.findByPk(Number(req.params.id));
if (!row) return res.status(404).json({ error: 'Not found' });
await row.destroy();
res.json({ ok: true });
});
r.delete(
"/sub_categories/:id",
requireAuth,
enrichPermissions(),
requirePerm("lookup:edit"),
async (req, res) => {
const row = await SubCat.findByPk(Number(req.params.id));
if (!row) return res.status(404).json({ error: "Not found" });
await row.destroy();
res.json({ ok: true });
}
);
export default r;

View File

@@ -1,4 +1,36 @@
// src/routes/technicaldocs.js (ESM)
// 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';

View File

@@ -1,32 +1,45 @@
// backend/src/routes/transmittals.js (merged)
// 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)
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';
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');
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'],
["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';
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 }) {
@@ -38,15 +51,24 @@ function parsePaging({ page = 1, pageSize = 20 }) {
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 (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)');
extra.push("(t.tr_no LIKE :q OR t.subject LIKE :q)");
params.q = `%${q}%`;
}
return { where: extra.join(' AND '), params };
return { where: extra.join(" AND "), params };
}
/* -------------------------------- LIST --------------------------------
@@ -54,24 +76,31 @@ GET /transmittals
- คง RBAC/Scope เดิม (global + project/org scope ผ่าน buildScopeWhere)
- เพิ่ม sort/page/pageSize/q ตามสไตล์ transmittals-1.js และตอบ meta
------------------------------------------------------------------------*/
r.get('/',
requirePerm(PERM.transmittal.read, { scope: 'global' }),
r.get(
"/",
requirePerm(PERM.transmittal.read, { scope: "global" }),
async (req, res) => {
try {
const { project_id, org_id, tr_no, q, sort, page, pageSize } = req.query;
const orderBy = parseSort(sort);
const { limit, offset, page: p, pageSize: ps } = parsePaging({ page, pageSize });
const {
limit,
offset,
page: p,
pageSize: ps,
} = parsePaging({ page, pageSize });
const base = buildScopeWhere(req.principal, {
tableAlias: 't',
orgColumn: 't.org_id',
projectColumn: 't.project_id',
tableAlias: "t",
orgColumn: "t.org_id",
projectColumn: "t.project_id",
permCode: PERM.transmittal.read,
preferProject: true
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 where =
[base.where, extra.where].filter(Boolean).join(" AND ") || "1=1";
const params = { ...base.params, ...extra.params, limit, offset };
// total
@@ -90,31 +119,48 @@ r.get('/',
params
);
res.json({ data: rows, total: Number(total || 0), page: p, pageSize: ps });
res.json({
data: rows,
total: Number(total || 0),
page: p,
pageSize: ps,
});
} catch (e) {
res.status(500).json({ error: e.message || 'transmittals/list failed' });
res.status(500).json({ error: e.message || "transmittals/list failed" });
}
}
);
/* ------------------------------- GET ONE ------------------------------ */
r.get('/:id',
requirePerm(PERM.transmittal.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
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' });
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' });
res
.status(500)
.json({ error: e.message || "transmittals/detail failed" });
}
}
);
/* -------------------------------- CREATE ------------------------------ */
r.post('/',
requirePerm(PERM.transmittal.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
r.post(
"/",
requirePerm(PERM.transmittal.create, {
scope: "org",
getOrgId: async (req) => req.body?.org_id ?? null,
}),
async (req, res) => {
try {
// ยึดสคีมาหลักจาก transmittals.js
@@ -126,35 +172,50 @@ r.post('/',
);
res.status(201).json({ id: rs.insertId });
} catch (e) {
res.status(500).json({ error: e.message || 'transmittals/create failed' });
res
.status(500)
.json({ error: e.message || "transmittals/create failed" });
}
}
);
/* -------------------------------- UPDATE ------------------------------ */
// PUT: รูปแบบเดิม (อัปเดต subject, status)
r.put('/:id',
requirePerm(PERM.transmittal.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
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]);
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' });
res
.status(500)
.json({ error: e.message || "transmittals/update failed" });
}
}
);
// PATCH: อัปเดตบางฟิลด์ (ไม่บังคับคอลัมน์ใหม่ใน DB — จะอัปเดตก็ต่อเมื่อ body ส่งมา)
r.patch('/:id',
requirePerm(PERM.transmittal.update, { scope: 'org', getOrgId: OWN.getOrgIdById }),
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"];
// ถ้าฐานข้อมูลของคุณมีคอลัมน์เพิ่ม เช่น to_party, sent_date, description
// และต้องการอัปเดตด้วย ให้เพิ่มชื่อคอลัมน์เข้าไปใน allowed ได้
@@ -164,39 +225,55 @@ r.patch('/:id',
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' });
return res.status(400).json({ error: "no fields to update" });
}
if ('status' in patch) {
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 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}`);
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`,
`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' });
res.status(500).json({ error: e.message || "transmittals/patch failed" });
}
}
);
/* -------------------------------- DELETE ------------------------------ */
r.delete('/:id',
requirePerm(PERM.transmittal.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
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]);
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' });
res
.status(500)
.json({ error: e.message || "transmittals/delete failed" });
}
}
);

View File

@@ -1,67 +1,125 @@
import { Router } from 'express';
import multer from 'multer';
import fs from 'node:fs';
import path from 'node:path';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import PERM from '../config/permissions.js';
// FILE: 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
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) { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }
const UPLOAD_BASE = process.env.UPLOAD_BASE || "/share/dms-data";
function ensureDir(p) {
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
}
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
try {
const { module, id } = req.params;
const [[row]] = await sql.query(`SELECT org_id, project_id, created_at FROM ${module} WHERE id=?`, [Number(id)]);
if (!row) return cb(new Error('Resource not found'));
const dt = new Date(row.created_at || Date.now());
const ym = `${dt.getUTCFullYear()}-${String(dt.getUTCMonth()+1).padStart(2,'0')}`;
const dir = path.join(UPLOAD_BASE, module, String(row.org_id), String(row.project_id), ym);
ensureDir(dir);
cb(null, dir);
} catch (e) { cb(e); }
},
filename: (req, file, cb) => {
const ts = Date.now();
const safe = file.originalname.replace(/[\^\w.\-]+/g, '_');
cb(null, `${ts}__${safe}`);
}
destination: async (req, file, cb) => {
try {
const { module, id } = req.params;
const [[row]] = await sql.query(
`SELECT org_id, project_id, created_at FROM ${module} WHERE id=?`,
[Number(id)]
);
if (!row) return cb(new Error("Resource not found"));
const dt = new Date(row.created_at || Date.now());
const ym = `${dt.getUTCFullYear()}-${String(
dt.getUTCMonth() + 1
).padStart(2, "0")}`;
const dir = path.join(
UPLOAD_BASE,
module,
String(row.org_id),
String(row.project_id),
ym
);
ensureDir(dir);
cb(null, dir);
} catch (e) {
cb(e);
}
},
filename: (req, file, cb) => {
const ts = Date.now();
const safe = file.originalname.replace(/[\^\w.\-]+/g, "_");
cb(null, `${ts}__${safe}`);
},
});
const upload = multer({ storage });
const PERM_UPLOAD = {
correspondences: PERM.correspondence.upload,
rfas: PERM.rfa.upload,
drawings: PERM.drawing.upload,
transmittals: PERM.transmittal?.upload,
correspondences: PERM.correspondence.upload,
rfas: PERM.rfa.upload,
drawings: PERM.drawing.upload,
transmittals: PERM.transmittal?.upload,
};
async function getProjectIdByModule(req){
const { module, id } = req.params;
const [[row]] = await sql.query(`SELECT project_id FROM ${module} WHERE id=?`, [Number(id)]);
return row?.project_id ?? null;
async function getProjectIdByModule(req) {
const { module, id } = req.params;
const [[row]] = await sql.query(
`SELECT project_id FROM ${module} WHERE id=?`,
[Number(id)]
);
return row?.project_id ?? null;
}
r.post('/:module/:id/file',
(req, res, next) => {
const perm = PERM_UPLOAD[req.params.module];
if (!perm) return res.status(400).json({ error: 'Unsupported module' });
return requirePerm(perm, { scope: 'project', getProjectId: getProjectIdByModule })(req, res, next);
},
upload.single('file'),
async (req, res) => {
const { module, id } = req.params;
const file = req.file;
res.json({ ok: 1, module, ref_id: Number(id), filename: file.filename, path: file.path, size: file.size, mime: file.mimetype });
}
r.post(
"/:module/:id/file",
(req, res, next) => {
const perm = PERM_UPLOAD[req.params.module];
if (!perm) return res.status(400).json({ error: "Unsupported module" });
return requirePerm(perm, {
scope: "project",
getProjectId: getProjectIdByModule,
})(req, res, next);
},
upload.single("file"),
async (req, res) => {
const { module, id } = req.params;
const file = req.file;
res.json({
ok: 1,
module,
ref_id: Number(id),
filename: file.filename,
path: file.path,
size: file.size,
mime: file.mimetype,
});
}
);
export default r;
export default r;

View File

@@ -1,32 +1,51 @@
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import PERM from '../config/permissions.js';
// FILE: src/routes/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
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
r.get('/me', async (req, res) => {
const [[u]] = await sql.query('SELECT user_id, username, email, first_name, last_name FROM users WHERE user_id=?',
[req.principal.userId]);
if (!u) return res.status(404).json({ error: 'User not found' });
r.get("/me", async (req, res) => {
const [[u]] = await sql.query(
"SELECT user_id, username, email, first_name, last_name FROM users WHERE user_id=?",
[req.principal.userId]
);
if (!u) return res.status(404).json({ error: "User not found" });
// roles in plain
const [roles] = await sql.query(`
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]);
WHERE ur.user_id=?`,
[req.principal.userId]
);
res.json({ ...u, roles, role_codes: [...req.principal.roleCodes] });
});
// (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);
}
);
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);
});
export default r;

View File

@@ -1,28 +1,51 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth.js';
import { requireRole } from '../middleware/rbac.js';
import { User } from '../db/sequelize.js';
import { hashPassword } from '../utils/passwords.js';
import { sequelize } from '../db/sequelize.js';
import UPRModel from '../db/models/UserProjectRole.js';
import ProjectModel from '../db/models/Project.js';
// FILE: 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
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";
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) => {
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' });
const isAdmin = (req.user?.roles || []).includes("Admin");
if (!isSelf && !isAdmin) return res.status(403).json({ error: "Forbidden" });
const { new_password } = req.body || {};
if (!new_password) return res.status(400).json({ error: 'new_password required' });
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' });
if (!row) return res.status(404).json({ error: "Not found" });
row.password_hash = await hashPassword(new_password);
await row.save();
@@ -30,25 +53,40 @@ r.patch('/users/:id/password', requireAuth, async (req, res) => {
});
// 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'] });
r.get("/users/search", requireAuth, requireRole("Admin"), async (req, res) => {
const q = String(req.query.q || "").toLowerCase();
const where = q
? {
username: sequelize.where(
sequelize.fn("LOWER", sequelize.col("username")),
"LIKE",
`%${q}%`
),
}
: {};
const rows = await User.findAll({
where,
limit: 20,
order: [["username", "ASC"]],
attributes: ["user_id", "username", "first_name", "last_name", "email"],
});
res.json(rows);
});
// my projects/roles
r.get('/users/me/projects', requireAuth, async (req, res) => {
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' });
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 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 }));
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);
});

View File

@@ -1,86 +1,160 @@
// src/routes/view.js
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import { buildScopeWhere, ownerResolvers } from '../utils/scope.js';
import PERM from '../config/permissions.js';
// FILE: 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
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');
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' }),
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',
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}%`; }
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 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
LIMIT :limit OFFSET :offset`,
params
);
res.json(rows);
}
);
// GET by id
r.get('/:id',
requirePerm(PERM.savedview.read, { scope: 'org', getOrgId: OWN.getOrgIdById }),
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=?', [id]);
if (!row) return res.status(404).json({ error: 'Not found' });
const [[row]] = await sql.query("SELECT * FROM saved_views WHERE id=?", [
id,
]);
if (!row) return res.status(404).json({ error: "Not found" });
res.json(row);
}
);
// CREATE
r.post('/',
requirePerm(PERM.savedview.create, { scope: 'org', getOrgId: async req => req.body?.org_id ?? null }),
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]
[
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 }),
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' });
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=?',
"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 });
@@ -88,11 +162,15 @@ r.put('/:id',
);
// DELETE
r.delete('/:id',
requirePerm(PERM.savedview.delete, { scope: 'org', getOrgId: OWN.getOrgIdById }),
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]);
await sql.query("DELETE FROM saved_views WHERE id=?", [id]);
res.json({ ok: 1 });
}
);

View File

@@ -1,28 +1,36 @@
// src/routes/views.js (ESM)
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import PERM from '../config/permissions.js';
// FILE: 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
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';
const DB_NAME = process.env.DB_NAME || "dms_db";
// LIST views
r.get('/',
requirePerm(PERM.viewdef.read, { scope: 'global' }),
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
FROM information_schema.VIEWS
WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME`, [DB_NAME]
WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME`,
[DB_NAME]
);
res.json(rows);
}
);
// GET view definition
r.get('/:view_name',
requirePerm(PERM.viewdef.read, { scope: 'global' }),
r.get(
"/:view_name",
requirePerm(PERM.viewdef.read, { scope: "global" }),
async (req, res) => {
const viewName = req.params.view_name;
const [[row]] = await sql.query(
@@ -31,7 +39,7 @@ r.get('/:view_name',
WHERE TABLE_SCHEMA=? AND TABLE_NAME=?`,
[DB_NAME, viewName]
);
if (!row) return res.status(404).json({ error: 'Not found' });
if (!row) return res.status(404).json({ error: "Not found" });
res.json({ view: viewName, definition: row.definition });
}
);

View File

@@ -1,50 +1,70 @@
import { Router } from 'express';
import sql from '../db/index.js';
import { requirePerm } from '../middleware/requirePerm.js';
import PERM from '../config/permissions.js';
// FILE: src/routes/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
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(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: 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);
}
);
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 });
}
// 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.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 });
}
// 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.delete('/:id',
requirePerm(PERM.volume.delete, { scope: 'global' }),
async (req, res) => {
const id = Number(req.params.id);
await sql.query('DELETE FROM volumes WHERE volume_id=?', [id]);
res.json({ ok: 1 });
}
// DELETE
r.delete(
"/:id",
requirePerm(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 });
}
);
export default r;
export default r;

View File

@@ -1,4 +1,14 @@
import bcrypt from 'bcrypt';
// FILE: src/utils/passwords.js
// Password hashing and verification utilities
// - Uses bcrypt for secure password hashing
// - Provides hashPassword(plain) and verifyPassword(plain, hash) functions
// - hashPassword returns a promise that resolves to the hashed password
// - verifyPassword returns a promise that resolves to true/false
// - Uses 10 salt rounds for hashing
// - Assumes bcrypt package is installed
// - Suitable for user authentication systems
// - Can be used in user registration and login flows
import bcrypt from "bcrypt";
export async function hashPassword(plain) {
const saltRounds = 10;
return bcrypt.hash(plain, saltRounds);

View File

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

View File

@@ -1,3 +1,14 @@
// FILE: src/utils/scope.js
// 03.2 5) เพิ่ม utils/scope.js (ใหม่)
// - ใช้ร่วมกับ requirePerm() และ loadPrincipal()
// - สำหรับสร้าง SQL WHERE clause ในการ list entities ตาม scope ของผู้ใช้
// Scope and permission utilities
// - Functions to build SQL WHERE clauses based on user principal and permissions
// - Used for filtering list queries according to user's
// roles, permissions, and associated orgs/projects
// - Works with rbac.js loadPrincipal() output
// - Supports SUPER_ADMIN, ADMIN, and scoped permissions
/**
* สร้าง WHERE fragment + params สำหรับ list ตาม principal
* - SUPER_ADMIN: ไม่จำกัด
@@ -12,17 +23,18 @@
* permCode: permission_code ที่ใช้สำหรับ read list (เช่น 'correspondence.read')
* preferProject: true -> บังคับต้อง match project scope ก่อน (ถ้ามี)
*/
export function buildScopeWhere(principal, {
tableAlias, orgColumn, projectColumn, permCode, preferProject = false,
}) {
if (principal.isSuperAdmin) return { where: '1=1', params: {} };
export function buildScopeWhere(
principal,
{ tableAlias, orgColumn, projectColumn, permCode, preferProject = false }
) {
if (principal.isSuperAdmin) return { where: "1=1", params: {} };
const perm = principal.perms.get(permCode);
const orgIds = new Set(principal.orgIds);
const projectIds = new Set(principal.projectIds);
// กรณี ADMIN: ให้ดูภายใน org/project ตัวเองได้ทั้งหมด (แต่ต้องถือ permCode)
if (principal.roleCodes.has('ADMIN') && perm) {
if (principal.roleCodes.has("ADMIN") && perm) {
const orgList = [...orgIds];
const prjList = [...projectIds];
if (preferProject && prjList.length > 0) {
@@ -38,11 +50,11 @@ export function buildScopeWhere(principal, {
};
}
// ถ้าไม่มี mapping เลย ให้ไม่เห็นอะไร
return { where: '1=0', params: {} };
return { where: "1=0", params: {} };
}
// บทบาทอื่น: อิงตาม perm scope
if (!perm) return { where: '1=0', params: {} };
if (!perm) return { where: "1=0", params: {} };
const permOrg = [...perm.orgIds];
const permPrj = [...perm.projectIds];
@@ -55,25 +67,31 @@ export function buildScopeWhere(principal, {
}
// ถ้า perm ไม่มี scope ผูก (global grant) ให้ผ่านทั้งหมด
return { where: '1=1', params: {} };
return { where: "1=1", params: {} };
}
/**
* owner resolvers: อ่าน org_id/project_id จาก DB ด้วย id
* ใช้กับ requirePerm(getOrgId/getProjectId) เสมอ
*/
export function ownerResolvers(sql, mainTable, idColumn = 'id') {
export function ownerResolvers(sql, mainTable, idColumn = "id") {
return {
async getOrgIdById(req) {
const id = Number(req.params.id ?? req.body?.id);
if (!id) return null;
const [[row]] = await sql.query(`SELECT org_id FROM ${mainTable} WHERE ${idColumn}=?`, [id]);
const [[row]] = await sql.query(
`SELECT org_id FROM ${mainTable} WHERE ${idColumn}=?`,
[id]
);
return row?.org_id ?? null;
},
async getProjectIdById(req) {
const id = Number(req.params.id ?? req.body?.id);
if (!id) return null;
const [[row]] = await sql.query(`SELECT project_id FROM ${mainTable} WHERE ${idColumn}=?`, [id]);
const [[row]] = await sql.query(
`SELECT project_id FROM ${mainTable} WHERE ${idColumn}=?`,
[id]
);
return row?.project_id ?? null;
},
};

View File

@@ -1,5 +1,14 @@
// File: frontend/app/(auth)/login/page· typescript
"use client";
// ✅ ปรับให้ตรง backend: ใช้ Bearer token (ไม่ใช้ cookie)
// - เรียก POST /api/auth/login → รับ { token, refresh_token, user }
// - เก็บ token/refresh_token ใน localStorage (หรือ sessionStorage ถ้าไม่ติ๊กจำไว้)
// - ไม่ใช้ credentials: "include" อีกต่อไป
// - เอา RootLayout/metadata ออก เพราะไฟล์เพจเป็น client component
// - เพิ่มการอ่าน NEXT_PUBLIC_API_BASE และ error handling ให้ตรงกับ backend
import { useState, useMemo } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import {
@@ -15,6 +24,8 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -42,25 +53,37 @@ export default function LoginPage() {
try {
setSubmitting(true);
// เรียก backend ให้ตั้ง HttpOnly cookie: access_token
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE}/api/auth/login`,
{
method: "POST",
credentials: "include", // สำคัญ: รับ/ส่งคุกกี้
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, remember }),
cache: "no-store",
}
);
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
cache: "no-store",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErr(data?.message || "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง");
// รองรับข้อความ error จาก backend เช่น INVALID_CREDENTIALS
setErr(
data?.error === "INVALID_CREDENTIALS"
? "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง"
: data?.error || "เข้าสู่ระบบไม่สำเร็จ"
);
return;
}
// สำเร็จ → backendั้งคุกกี้แล้ว → redirect
// ✅ เก็บ token ตามโหมดจำไว้/ไม่จำ
const storage = remember ? window.localStorage : window.sessionStorage;
storage.setItem("dms.token", data.token);
storage.setItem("dms.refresh_token", data.refresh_token);
storage.setItem("dms.user", JSON.stringify(data.user || {}));
// (ออปชัน) เผยแพร่ event ให้แท็บอื่นทราบ
try {
window.dispatchEvent(
new StorageEvent("storage", { key: "dms.auth", newValue: "login" })
);
} catch {}
router.replace(nextPath);
} catch (e) {
setErr("เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาลองใหม่");
@@ -70,102 +93,104 @@ export default function LoginPage() {
}
return (
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-sky-800">
เขาสระบบ
</CardTitle>
<CardDescription className="text-sky-700">
Document Management System LCBP3
</CardDescription>
</CardHeader>
<div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4">
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-sky-800">
เขาสระบบ
</CardTitle>
<CardDescription className="text-sky-700">
Document Management System LCBP3
</CardDescription>
</CardHeader>
<CardContent>
{err ? (
<Alert className="mb-4">
<AlertDescription>{err}</AlertDescription>
</Alert>
) : null}
<CardContent>
{err ? (
<Alert className="mb-4">
<AlertDescription>{err}</AlertDescription>
</Alert>
) : null}
<form onSubmit={onSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="username">อผใช</Label>
<Input
id="username"
autoFocus
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="เช่น superadmin"
disabled={submitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">รหสผาน</Label>
<div className="relative">
<form onSubmit={onSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="username">อผใช</Label>
<Input
id="password"
type={showPw ? "text" : "password"}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
id="username"
autoFocus
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="เช่น superadmin"
disabled={submitting}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPw((v) => !v)}
className="absolute inset-y-0 px-2 my-auto text-xs bg-white border rounded-md right-2 hover:bg-slate-50"
aria-label={showPw ? "ซ่อนรหัสผ่าน" : "แสดงรหัสผ่าน"}
disabled={submitting}
>
{showPw ? "Hide" : "Show"}
</button>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<label className="inline-flex items-center gap-2 text-sm text-slate-600">
<input
type="checkbox"
className="size-4 accent-sky-700"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
disabled={submitting}
/>
จดจำฉนไวในเครองน
</label>
<div className="grid gap-2">
<Label htmlFor="password">รหสผาน</Label>
<div className="relative">
<Input
id="password"
type={showPw ? "text" : "password"}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={submitting}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPw((v) => !v)}
className="absolute inset-y-0 px-2 my-auto text-xs bg-white border rounded-md right-2 hover:bg-slate-50"
aria-label={showPw ? "ซ่อนรหัสผ่าน" : "แสดงรหัสผ่าน"}
disabled={submitting}
>
{showPw ? "Hide" : "Show"}
</button>
</div>
</div>
<a
href="/forgot"
className="text-sm text-sky-700 hover:text-sky-900 hover:underline"
<div className="flex items-center justify-between pt-1">
<label className="inline-flex items-center gap-2 text-sm text-slate-600">
<input
type="checkbox"
className="size-4 accent-sky-700"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
disabled={submitting}
/>
จดจำฉนไวในเครองน
</label>
<a
href="/forgot-password"
className="text-sm text-sky-700 hover:text-sky-900 hover:underline"
>
มรหสผาน?
</a>
</div>
<Button
type="submit"
disabled={submitting}
className="mt-2 bg-sky-700 hover:bg-sky-800"
>
มรหสผาน?
</a>
</div>
{submitting ? (
<span className="inline-flex items-center gap-2">
<Spinner /> กำลงเขาสระบบ
</span>
) : (
"เข้าสู่ระบบ"
)}
</Button>
</form>
</CardContent>
<Button
type="submit"
disabled={submitting}
className="mt-2 bg-sky-700 hover:bg-sky-800"
>
{submitting ? (
<span className="inline-flex items-center gap-2">
<Spinner /> กำลงเขาสระบบ
</span>
) : (
"เข้าสู่ระบบ"
)}
</Button>
</form>
</CardContent>
<CardFooter className="text-xs text-center text-slate-500">
&copy; {new Date().getFullYear()} np-dms.work
</CardFooter>
</Card>
<CardFooter className="text-xs text-center text-slate-500">
&copy; {new Date().getFullYear()} np-dms.work
</CardFooter>
</Card>
</div>
);
}

View File

@@ -1,6 +1,6 @@
{
"name": "dms-frontend",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"engines": {
"node": ">=20.0.0"

View File

@@ -0,0 +1,31 @@
-- 01_99_dms_data_v5_1_deploy_table_password_resets.sql
-- Purpose: Table for password reset flow (forgot/reset password)
-- Depends on: users
SET NAMES utf8mb4;
SET time_zone = '+00:00';
-- ใช้ IF NOT EXISTS ป้องกันรันซ้ำ
CREATE TABLE IF NOT EXISTS password_resets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
token_hash CHAR(64) NOT NULL COMMENT 'SHA-256(hex) ของ token ดิบที่ส่งให้ผู้ใช้',
expires_at DATETIME NOT NULL,
used_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_password_resets_token_hash (token_hash),
KEY idx_password_resets_user_id (user_id),
KEY idx_password_resets_expires (expires_at),
CONSTRAINT fk_password_resets_user
FOREIGN KEY (user_id) REFERENCES users(user_id)
ON DELETE CASCADE ON UPDATE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- (ออปชัน) กำจัด token ที่หมดอายุอัตโนมัติด้วย EVENT (หากเปิด event_scheduler)
-- CREATE EVENT IF NOT EXISTS ev_prune_password_resets
-- ON SCHEDULE EVERY 1 DAY
-- DO DELETE FROM password_resets WHERE (used_at IS NOT NULL) OR (expires_at < NOW());
-- DOWN (rollback)
-- DROP TABLE IF EXISTS password_resets;