Working version before modification.
This commit is contained in:
+25
-7
@@ -1,9 +1,13 @@
|
||||
import cookieParser from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import { errorHandler, notFoundHandler } from './middleware/errorHandler.js';
|
||||
import { requireAdminAuth, requireUserAuth } from './middleware/authMiddleware.js';
|
||||
import adminRoutes from './routes/adminRoutes.js';
|
||||
import authRoutes from './routes/authRoutes.js';
|
||||
import configRoutes from './routes/configRoutes.js';
|
||||
import healthRoutes from './routes/healthRoutes.js';
|
||||
import lookupRoutes from './routes/lookupRoutes.js';
|
||||
@@ -26,9 +30,12 @@ const publicDir = fileURLToPath(new URL('../public', import.meta.url));
|
||||
const userPagePath = path.join(publicDir, 'user.html');
|
||||
const adminPagePath = path.join(publicDir, 'admin.html');
|
||||
const portalPath = path.join(publicDir, 'portal.html');
|
||||
const loginAdminPath = path.join(publicDir, 'login-admin.html');
|
||||
const loginUserPath = path.join(publicDir, 'login-user.html');
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
|
||||
app.get('/api/v1', (_req, res) => {
|
||||
res.json({
|
||||
@@ -48,11 +55,23 @@ app.use('/api/v1/templates', templateRoutes);
|
||||
app.use('/api/v1/lookups', lookupRoutes);
|
||||
app.use('/api/v1/config', configRoutes);
|
||||
app.use('/api/v1/reports', reportRoutes);
|
||||
app.use('/api/v1/admin', adminRoutes);
|
||||
app.use('/api/v1/auth', authRoutes);
|
||||
|
||||
/*
|
||||
* Login pages are served without authentication.
|
||||
*/
|
||||
app.get('/login-admin', (_req, res) => {
|
||||
res.sendFile(loginAdminPath);
|
||||
});
|
||||
|
||||
app.get('/login-user', (_req, res) => {
|
||||
res.sendFile(loginUserPath);
|
||||
});
|
||||
|
||||
/*
|
||||
* The root route intentionally serves a neutral portal page. This gives the
|
||||
* project distinct user and administrator entry points without introducing a
|
||||
* full authentication flow yet.
|
||||
* project distinct user and administrator entry points.
|
||||
*/
|
||||
app.get('/', (_req, res) => {
|
||||
res.sendFile(portalPath);
|
||||
@@ -60,14 +79,13 @@ app.get('/', (_req, res) => {
|
||||
|
||||
/*
|
||||
* User and admin workspaces live in separate HTML files so each page only loads
|
||||
* the markup it needs. The shared frontend JavaScript (app.js) detects which
|
||||
* elements are present and binds behavior accordingly.
|
||||
* the markup it needs. Authentication is required for both areas.
|
||||
*/
|
||||
app.get(['/user', '/user/'], (_req, res) => {
|
||||
app.get(['/user', '/user/'], requireUserAuth, (_req, res) => {
|
||||
res.sendFile(userPagePath);
|
||||
});
|
||||
|
||||
app.get(['/admin', '/admin/'], (_req, res) => {
|
||||
app.get(['/admin', '/admin/'], requireAdminAuth, (_req, res) => {
|
||||
res.sendFile(adminPagePath);
|
||||
});
|
||||
app.use(express.static(publicDir));
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Authentication middleware for protecting routes.
|
||||
*
|
||||
* Provides middleware functions to:
|
||||
* - requireAdminAuth - Protect admin-only routes
|
||||
* - requireUserAuth - Protect user-only routes
|
||||
* - requireAnyAuth - Protect routes requiring any authenticated user
|
||||
*/
|
||||
|
||||
import { validateSession } from '../services/authService.js';
|
||||
|
||||
/**
|
||||
* Extract auth token from request (cookie or Authorization header).
|
||||
*/
|
||||
function getAuthToken(req) {
|
||||
return req.cookies?.auth_token || req.headers.authorization?.replace('Bearer ', '') || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require admin authentication.
|
||||
* Redirects to login page for HTML requests, returns 401 for API requests.
|
||||
*/
|
||||
export function requireAdminAuth(req, res, next) {
|
||||
const token = getAuthToken(req);
|
||||
const session = token ? validateSession(token) : null;
|
||||
|
||||
if (!session || session.type !== 'admin') {
|
||||
/* Check if this is an API request or page request */
|
||||
const isApiRequest = req.path.startsWith('/api/') || req.xhr || req.headers.accept?.includes('application/json');
|
||||
|
||||
if (isApiRequest) {
|
||||
return res.status(401).json({ message: 'Admin authentication required.' });
|
||||
}
|
||||
|
||||
/* Redirect to admin login page */
|
||||
return res.redirect('/login-admin');
|
||||
}
|
||||
|
||||
req.session = session;
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require user authentication.
|
||||
* Redirects to login page for HTML requests, returns 401 for API requests.
|
||||
*/
|
||||
export function requireUserAuth(req, res, next) {
|
||||
const token = getAuthToken(req);
|
||||
const session = token ? validateSession(token) : null;
|
||||
|
||||
if (!session || session.type !== 'user') {
|
||||
const isApiRequest = req.path.startsWith('/api/') || req.xhr || req.headers.accept?.includes('application/json');
|
||||
|
||||
if (isApiRequest) {
|
||||
return res.status(401).json({ message: 'User authentication required.' });
|
||||
}
|
||||
|
||||
/* Redirect to user login page */
|
||||
return res.redirect('/login-user');
|
||||
}
|
||||
|
||||
req.session = session;
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require any authentication (admin or user).
|
||||
*/
|
||||
export function requireAnyAuth(req, res, next) {
|
||||
const token = getAuthToken(req);
|
||||
const session = token ? validateSession(token) : null;
|
||||
|
||||
if (!session) {
|
||||
const isApiRequest = req.path.startsWith('/api/') || req.xhr || req.headers.accept?.includes('application/json');
|
||||
|
||||
if (isApiRequest) {
|
||||
return res.status(401).json({ message: 'Authentication required.' });
|
||||
}
|
||||
|
||||
return res.redirect('/');
|
||||
}
|
||||
|
||||
req.session = session;
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { Router } from 'express';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
import * as svc from '../services/adminService.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/* ── Bulk load — single request to get all admin data ───────────────────── */
|
||||
|
||||
router.get(
|
||||
'/all',
|
||||
asyncHandler(async (_req, res) => {
|
||||
const data = await svc.loadAllAdminData();
|
||||
res.json(data);
|
||||
})
|
||||
);
|
||||
|
||||
/* ── Categories ─────────────────────────────────────────────────────────── */
|
||||
|
||||
router.get('/categories', asyncHandler(async (_req, res) => {
|
||||
res.json({ items: await svc.listCategories() });
|
||||
}));
|
||||
|
||||
router.post('/categories', asyncHandler(async (req, res) => {
|
||||
const { value } = req.body;
|
||||
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
|
||||
const result = await svc.createCategory(value.trim());
|
||||
res.status(201).json(result);
|
||||
}));
|
||||
|
||||
router.put('/categories/:id', asyncHandler(async (req, res) => {
|
||||
const { value } = req.body;
|
||||
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
|
||||
const result = await svc.updateCategory(Number(req.params.id), value.trim());
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
router.delete('/categories/:id', asyncHandler(async (req, res) => {
|
||||
await svc.deleteCategory(Number(req.params.id));
|
||||
res.json({ message: 'Deleted.' });
|
||||
}));
|
||||
|
||||
/* ── Sub-Categories ─────────────────────────────────────────────────────── */
|
||||
|
||||
router.get('/sub-categories', asyncHandler(async (_req, res) => {
|
||||
res.json({ items: await svc.listSubCategories() });
|
||||
}));
|
||||
|
||||
router.post('/sub-categories', asyncHandler(async (req, res) => {
|
||||
const { value, categoryId } = req.body;
|
||||
if (!value?.trim() || !categoryId) return res.status(400).json({ message: 'value and categoryId are required.' });
|
||||
const result = await svc.createSubCategory(value.trim(), Number(categoryId));
|
||||
res.status(201).json(result);
|
||||
}));
|
||||
|
||||
router.put('/sub-categories/:id', asyncHandler(async (req, res) => {
|
||||
const { value, categoryId } = req.body;
|
||||
if (!value?.trim() || !categoryId) return res.status(400).json({ message: 'value and categoryId are required.' });
|
||||
const result = await svc.updateSubCategory(Number(req.params.id), value.trim(), Number(categoryId));
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
router.delete('/sub-categories/:id', asyncHandler(async (req, res) => {
|
||||
await svc.deleteSubCategory(Number(req.params.id));
|
||||
res.json({ message: 'Deleted.' });
|
||||
}));
|
||||
|
||||
/* ── Severities ─────────────────────────────────────────────────────────── */
|
||||
|
||||
router.get('/severities', asyncHandler(async (_req, res) => {
|
||||
res.json({ items: await svc.listSeverities() });
|
||||
}));
|
||||
|
||||
router.post('/severities', asyncHandler(async (req, res) => {
|
||||
const { value } = req.body;
|
||||
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
|
||||
const result = await svc.createSeverity(value.trim());
|
||||
res.status(201).json(result);
|
||||
}));
|
||||
|
||||
router.put('/severities/:id', asyncHandler(async (req, res) => {
|
||||
const { value } = req.body;
|
||||
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
|
||||
const result = await svc.updateSeverity(Number(req.params.id), value.trim());
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
router.delete('/severities/:id', asyncHandler(async (req, res) => {
|
||||
await svc.deleteSeverity(Number(req.params.id));
|
||||
res.json({ message: 'Deleted.' });
|
||||
}));
|
||||
|
||||
/* ── Statuses ───────────────────────────────────────────────────────────── */
|
||||
|
||||
router.get('/statuses', asyncHandler(async (_req, res) => {
|
||||
res.json({ items: await svc.listStatuses() });
|
||||
}));
|
||||
|
||||
router.post('/statuses', asyncHandler(async (req, res) => {
|
||||
const { value, requireHandledBy, requireComment } = req.body;
|
||||
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
|
||||
const result = await svc.createStatus(value.trim(), !!requireHandledBy, !!requireComment);
|
||||
res.status(201).json(result);
|
||||
}));
|
||||
|
||||
router.put('/statuses/:id', asyncHandler(async (req, res) => {
|
||||
const { value, requireHandledBy, requireComment } = req.body;
|
||||
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
|
||||
const result = await svc.updateStatus(Number(req.params.id), value.trim(), !!requireHandledBy, !!requireComment);
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
router.delete('/statuses/:id', asyncHandler(async (req, res) => {
|
||||
await svc.deleteStatus(Number(req.params.id));
|
||||
res.json({ message: 'Deleted.' });
|
||||
}));
|
||||
|
||||
/* ── Handled By ─────────────────────────────────────────────────────────── */
|
||||
|
||||
router.get('/handled-by', asyncHandler(async (_req, res) => {
|
||||
res.json({ items: await svc.listHandledBy() });
|
||||
}));
|
||||
|
||||
router.post('/handled-by', asyncHandler(async (req, res) => {
|
||||
const { value } = req.body;
|
||||
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
|
||||
const result = await svc.createHandledBy(value.trim());
|
||||
res.status(201).json(result);
|
||||
}));
|
||||
|
||||
router.put('/handled-by/:id', asyncHandler(async (req, res) => {
|
||||
const { value } = req.body;
|
||||
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
|
||||
const result = await svc.updateHandledBy(Number(req.params.id), value.trim());
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
router.delete('/handled-by/:id', asyncHandler(async (req, res) => {
|
||||
await svc.deleteHandledBy(Number(req.params.id));
|
||||
res.json({ message: 'Deleted.' });
|
||||
}));
|
||||
|
||||
/* ── Projects ───────────────────────────────────────────────────────────── */
|
||||
|
||||
router.get('/projects', asyncHandler(async (_req, res) => {
|
||||
res.json({ items: await svc.listProjects() });
|
||||
}));
|
||||
|
||||
router.post('/projects', asyncHandler(async (req, res) => {
|
||||
const { value } = req.body;
|
||||
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
|
||||
const result = await svc.createProject(value.trim());
|
||||
res.status(201).json(result);
|
||||
}));
|
||||
|
||||
router.put('/projects/:id', asyncHandler(async (req, res) => {
|
||||
const { value } = req.body;
|
||||
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
|
||||
const result = await svc.updateProject(Number(req.params.id), value.trim());
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
router.delete('/projects/:id', asyncHandler(async (req, res) => {
|
||||
await svc.deleteProject(Number(req.params.id));
|
||||
res.json({ message: 'Deleted.' });
|
||||
}));
|
||||
|
||||
/* ── Processes ──────────────────────────────────────────────────────────── */
|
||||
|
||||
router.get('/processes', asyncHandler(async (_req, res) => {
|
||||
res.json({ items: await svc.listProcesses() });
|
||||
}));
|
||||
|
||||
router.post('/processes', asyncHandler(async (req, res) => {
|
||||
const { value, projectId } = req.body;
|
||||
if (!value?.trim() || !projectId) return res.status(400).json({ message: 'value and projectId are required.' });
|
||||
const result = await svc.createProcess(value.trim(), Number(projectId));
|
||||
res.status(201).json(result);
|
||||
}));
|
||||
|
||||
router.put('/processes/:id', asyncHandler(async (req, res) => {
|
||||
const { value, projectId } = req.body;
|
||||
if (!value?.trim() || !projectId) return res.status(400).json({ message: 'value and projectId are required.' });
|
||||
const result = await svc.updateProcess(Number(req.params.id), value.trim(), Number(projectId));
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
router.delete('/processes/:id', asyncHandler(async (req, res) => {
|
||||
await svc.deleteProcess(Number(req.params.id));
|
||||
res.json({ message: 'Deleted.' });
|
||||
}));
|
||||
|
||||
/* ── Users ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
router.get('/users', asyncHandler(async (_req, res) => {
|
||||
res.json({ items: await svc.listUsers() });
|
||||
}));
|
||||
|
||||
router.post('/users', asyncHandler(async (req, res) => {
|
||||
const { email, password, name, familyName, company, role } = req.body;
|
||||
if (!email?.trim() || !name?.trim() || !familyName?.trim() || !role?.trim()) {
|
||||
return res.status(400).json({ message: 'email, name, familyName, and role are required.' });
|
||||
}
|
||||
const result = await svc.createUser({ email: email.trim(), password: password || '', name: name.trim(), familyName: familyName.trim(), company: company || '', role: role.trim() });
|
||||
res.status(201).json(result);
|
||||
}));
|
||||
|
||||
router.put('/users/:id', asyncHandler(async (req, res) => {
|
||||
const { email, password, name, familyName, company, role } = req.body;
|
||||
if (!email?.trim() || !name?.trim() || !familyName?.trim() || !role?.trim()) {
|
||||
return res.status(400).json({ message: 'email, name, familyName, and role are required.' });
|
||||
}
|
||||
const result = await svc.updateUser(Number(req.params.id), { email: email.trim(), password: password || '', name: name.trim(), familyName: familyName.trim(), company: company || '', role: role.trim() });
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
router.delete('/users/:id', asyncHandler(async (req, res) => {
|
||||
await svc.deleteUser(Number(req.params.id));
|
||||
res.json({ message: 'Deleted.' });
|
||||
}));
|
||||
|
||||
/* ── Sites ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
router.get('/sites', asyncHandler(async (_req, res) => {
|
||||
res.json({ items: await svc.listSites() });
|
||||
}));
|
||||
|
||||
router.post('/sites', asyncHandler(async (req, res) => {
|
||||
const { siteCode, host, obeSiteCode, pxsSiteCode } = req.body;
|
||||
if (!siteCode?.trim()) return res.status(400).json({ message: 'siteCode is required.' });
|
||||
const result = await svc.createSite({ siteCode: siteCode.trim(), host: host || '', obeSiteCode: obeSiteCode || '', pxsSiteCode: pxsSiteCode || '' });
|
||||
res.status(201).json(result);
|
||||
}));
|
||||
|
||||
router.put('/sites/:id', asyncHandler(async (req, res) => {
|
||||
const { siteCode, host, obeSiteCode, pxsSiteCode } = req.body;
|
||||
if (!siteCode?.trim()) return res.status(400).json({ message: 'siteCode is required.' });
|
||||
const result = await svc.updateSite(Number(req.params.id), { siteCode: siteCode.trim(), host: host || '', obeSiteCode: obeSiteCode || '', pxsSiteCode: pxsSiteCode || '' });
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
router.delete('/sites/:id', asyncHandler(async (req, res) => {
|
||||
await svc.deleteSite(Number(req.params.id));
|
||||
res.json({ message: 'Deleted.' });
|
||||
}));
|
||||
|
||||
/* ── CL Records ─────────────────────────────────────────────────────────── */
|
||||
|
||||
router.get('/cl-records', asyncHandler(async (_req, res) => {
|
||||
res.json({ items: await svc.listClRecords() });
|
||||
}));
|
||||
|
||||
router.post('/cl-records', asyncHandler(async (req, res) => {
|
||||
const data = req.body;
|
||||
if (data.sort == null) return res.status(400).json({ message: 'sort is required.' });
|
||||
const result = await svc.createClRecord(data);
|
||||
res.status(201).json(result);
|
||||
}));
|
||||
|
||||
router.put('/cl-records/:id', asyncHandler(async (req, res) => {
|
||||
const data = req.body;
|
||||
if (data.sort == null) return res.status(400).json({ message: 'sort is required.' });
|
||||
const result = await svc.updateClRecord(Number(req.params.id), data);
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
router.delete('/cl-records/:id', asyncHandler(async (req, res) => {
|
||||
await svc.deleteClRecord(Number(req.params.id));
|
||||
res.json({ message: 'Deleted.' });
|
||||
}));
|
||||
|
||||
/* ── CL Templates ───────────────────────────────────────────────────────── */
|
||||
|
||||
router.get('/cl-templates', asyncHandler(async (_req, res) => {
|
||||
res.json({ items: await svc.listClTemplates() });
|
||||
}));
|
||||
|
||||
router.post('/cl-templates', asyncHandler(async (req, res) => {
|
||||
const data = req.body;
|
||||
if (!data.name?.trim()) return res.status(400).json({ message: 'name is required.' });
|
||||
const result = await svc.createClTemplate(data);
|
||||
res.status(201).json(result);
|
||||
}));
|
||||
|
||||
router.put('/cl-templates/:id', asyncHandler(async (req, res) => {
|
||||
const data = req.body;
|
||||
if (!data.name?.trim()) return res.status(400).json({ message: 'name is required.' });
|
||||
const result = await svc.updateClTemplate(Number(req.params.id), data);
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
router.delete('/cl-templates/:id', asyncHandler(async (req, res) => {
|
||||
await svc.deleteClTemplate(Number(req.params.id));
|
||||
res.json({ message: 'Deleted.' });
|
||||
}));
|
||||
|
||||
/* ── Tasks ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
router.get('/tasks', asyncHandler(async (_req, res) => {
|
||||
res.json({ items: await svc.listTasks() });
|
||||
}));
|
||||
|
||||
router.post('/tasks', asyncHandler(async (req, res) => {
|
||||
const { siteId, userId, templateId, project, process } = req.body;
|
||||
if (!siteId || !userId || !templateId) {
|
||||
return res.status(400).json({ message: 'siteId, userId, and templateId are required.' });
|
||||
}
|
||||
const result = await svc.createTask({ siteId: Number(siteId), userId: Number(userId), templateId: Number(templateId), project: project || '', process: process || '', status: 'pending' });
|
||||
res.status(201).json(result);
|
||||
}));
|
||||
|
||||
router.put('/tasks/:id', asyncHandler(async (req, res) => {
|
||||
const { siteId, userId, templateId, project, process, status } = req.body;
|
||||
if (!siteId || !userId || !templateId) {
|
||||
return res.status(400).json({ message: 'siteId, userId, and templateId are required.' });
|
||||
}
|
||||
const result = await svc.updateTask(Number(req.params.id), { siteId: Number(siteId), userId: Number(userId), templateId: Number(templateId), project: project || '', process: process || '', status: status || 'pending' });
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
router.delete('/tasks/:id', asyncHandler(async (req, res) => {
|
||||
await svc.deleteTask(Number(req.params.id));
|
||||
res.json({ message: 'Deleted.' });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Authentication routes for the PoC application.
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - POST /auth/admin/login - Admin login
|
||||
* - POST /auth/user/login - User login
|
||||
* - POST /auth/logout - Logout (both admin and user)
|
||||
* - GET /auth/check - Check current session validity
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
import {
|
||||
verifyAdminCredentials,
|
||||
verifyUserCredentials,
|
||||
generateSessionToken,
|
||||
createSession,
|
||||
removeSession,
|
||||
validateSession
|
||||
} from '../services/authService.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Admin login endpoint.
|
||||
* Expects: { username: string, password: string }
|
||||
* Returns: { success: true, token: string } or { success: false, message: string }
|
||||
*/
|
||||
router.post(
|
||||
'/admin/login',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ success: false, message: 'Username and password required.' });
|
||||
}
|
||||
|
||||
const result = await verifyAdminCredentials(username, password);
|
||||
|
||||
if (!result.valid) {
|
||||
return res.status(401).json({ success: false, message: 'Invalid credentials.' });
|
||||
}
|
||||
|
||||
const token = generateSessionToken();
|
||||
createSession(token, { type: 'admin', ...result.admin });
|
||||
|
||||
/* Set cookie for browser-based auth */
|
||||
res.cookie('auth_token', token, {
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
sameSite: 'lax'
|
||||
});
|
||||
|
||||
return res.json({ success: true, token, admin: result.admin });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* User login endpoint.
|
||||
* Expects: { email: string, password: string }
|
||||
* Returns: { success: true, token: string, user: object } or { success: false, message: string }
|
||||
*/
|
||||
router.post(
|
||||
'/user/login',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ success: false, message: 'Email and password required.' });
|
||||
}
|
||||
|
||||
const result = await verifyUserCredentials(email, password);
|
||||
|
||||
if (!result.valid) {
|
||||
return res.status(401).json({ success: false, message: 'Invalid credentials.' });
|
||||
}
|
||||
|
||||
const token = generateSessionToken();
|
||||
createSession(token, { type: 'user', ...result.user });
|
||||
|
||||
/* Set cookie for browser-based auth */
|
||||
res.cookie('auth_token', token, {
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
sameSite: 'lax'
|
||||
});
|
||||
|
||||
return res.json({ success: true, token, user: result.user });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Logout endpoint - clears session.
|
||||
*/
|
||||
router.post('/logout', (req, res) => {
|
||||
const token = req.cookies?.auth_token || req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (token) {
|
||||
removeSession(token);
|
||||
}
|
||||
|
||||
res.clearCookie('auth_token');
|
||||
return res.json({ success: true, message: 'Logged out.' });
|
||||
});
|
||||
|
||||
/**
|
||||
* Check current session validity.
|
||||
* Returns session data if valid, 401 if not.
|
||||
*/
|
||||
router.get('/check', (req, res) => {
|
||||
const token = req.cookies?.auth_token || req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ authenticated: false, message: 'No session token.' });
|
||||
}
|
||||
|
||||
const session = validateSession(token);
|
||||
|
||||
if (!session) {
|
||||
res.clearCookie('auth_token');
|
||||
return res.status(401).json({ authenticated: false, message: 'Session expired or invalid.' });
|
||||
}
|
||||
|
||||
return res.json({ authenticated: true, session });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -2,9 +2,11 @@ import { Router } from 'express';
|
||||
|
||||
import {
|
||||
getAppConfig,
|
||||
getAppConfigValue,
|
||||
getExportProfile,
|
||||
getImageRules,
|
||||
updateImageRules
|
||||
updateImageRules,
|
||||
upsertAppConfig
|
||||
} from '../services/configService.js';
|
||||
import { logAuditEvent } from '../services/auditService.js';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
@@ -154,4 +156,36 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/app-config/:key',
|
||||
asyncHandler(async (req, res) => {
|
||||
const value = await getAppConfigValue(req.params.key);
|
||||
|
||||
if (value === null) {
|
||||
return res.status(404).json({ message: 'Config key not found.' });
|
||||
}
|
||||
|
||||
return res.json({ key: req.params.key, value });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/app-config/:key',
|
||||
asyncHandler(async (req, res) => {
|
||||
const key = req.params.key;
|
||||
|
||||
if (!key || typeof key !== 'string' || key.length > 100) {
|
||||
return res.status(400).json({ message: 'Invalid config key.' });
|
||||
}
|
||||
|
||||
if (req.body?.value === undefined) {
|
||||
return res.status(400).json({ message: 'Request body must include a value property.' });
|
||||
}
|
||||
|
||||
const result = await upsertAppConfig(key, req.body.value);
|
||||
configCache.invalidate(key);
|
||||
return res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
import { getReport, listReports, submitReport } from '../services/reportService.js';
|
||||
import { deleteReport, deleteReportImage, getReport, getReportImages, listReports, submitReport } from '../services/reportService.js';
|
||||
import { logAuditEvent } from '../services/auditService.js';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
import { validateParam } from '../middleware/validateParams.js';
|
||||
@@ -29,7 +29,7 @@ router.get(
|
||||
|
||||
router.get(
|
||||
'/:reportId',
|
||||
validateParam('reportId'),
|
||||
validateParam('reportId', { pattern: /^[a-zA-Z0-9_-]{1,100}$/ }),
|
||||
asyncHandler(async (req, res) => {
|
||||
const report = await getReport(req.params.reportId);
|
||||
|
||||
@@ -72,4 +72,44 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
/* Get all images for a report grouped by record */
|
||||
router.get(
|
||||
'/:reportId/images',
|
||||
validateParam('reportId', { pattern: /^[a-zA-Z0-9_-]{1,100}$/ }),
|
||||
asyncHandler(async (req, res) => {
|
||||
const images = await getReportImages(req.params.reportId);
|
||||
return res.json(images);
|
||||
})
|
||||
);
|
||||
|
||||
/* Delete a report and all associated images */
|
||||
router.delete(
|
||||
'/:reportId',
|
||||
validateParam('reportId', { pattern: /^[a-zA-Z0-9_-]{1,100}$/ }),
|
||||
asyncHandler(async (req, res) => {
|
||||
await deleteReport(req.params.reportId);
|
||||
await logAuditEvent({
|
||||
entityType: 'report',
|
||||
entityCode: req.params.reportId,
|
||||
action: 'delete',
|
||||
newValue: null
|
||||
});
|
||||
return res.json({ message: 'Report and images deleted.' });
|
||||
})
|
||||
);
|
||||
|
||||
/* Safe pattern for image file names: alphanumeric, underscore, hyphen, dot */
|
||||
const SAFE_FILENAME_PATTERN = /^[a-zA-Z0-9_.-]{1,500}$/;
|
||||
|
||||
/* Delete a specific image from a report */
|
||||
router.delete(
|
||||
'/:reportId/images/:recordId/:fileName',
|
||||
validateParam('reportId', { pattern: /^[a-zA-Z0-9_-]{1,100}$/ }),
|
||||
validateParam('fileName', { pattern: SAFE_FILENAME_PATTERN }),
|
||||
asyncHandler(async (req, res) => {
|
||||
await deleteReportImage(req.params.reportId, req.params.recordId, req.params.fileName);
|
||||
return res.json({ message: 'Image deleted.' });
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import { query } from '../db/pool.js';
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* CATEGORIES
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export async function listCategories() {
|
||||
return query('SELECT id, value FROM admin_categories ORDER BY value ASC');
|
||||
}
|
||||
|
||||
export async function createCategory(value) {
|
||||
const result = await query('INSERT INTO admin_categories (value) VALUES (?)', [value]);
|
||||
return { id: Number(result.insertId), value };
|
||||
}
|
||||
|
||||
export async function updateCategory(id, value) {
|
||||
await query('UPDATE admin_categories SET value = ? WHERE id = ?', [value, id]);
|
||||
return { id, value };
|
||||
}
|
||||
|
||||
export async function deleteCategory(id) {
|
||||
await query('DELETE FROM admin_categories WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* SUB-CATEGORIES
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export async function listSubCategories() {
|
||||
return query('SELECT id, value, category_id AS categoryId FROM admin_sub_categories ORDER BY value ASC');
|
||||
}
|
||||
|
||||
export async function createSubCategory(value, categoryId) {
|
||||
const result = await query('INSERT INTO admin_sub_categories (value, category_id) VALUES (?, ?)', [value, categoryId]);
|
||||
return { id: Number(result.insertId), value, categoryId };
|
||||
}
|
||||
|
||||
export async function updateSubCategory(id, value, categoryId) {
|
||||
await query('UPDATE admin_sub_categories SET value = ?, category_id = ? WHERE id = ?', [value, categoryId, id]);
|
||||
return { id, value, categoryId };
|
||||
}
|
||||
|
||||
export async function deleteSubCategory(id) {
|
||||
await query('DELETE FROM admin_sub_categories WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* SEVERITIES
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export async function listSeverities() {
|
||||
return query('SELECT id, value FROM admin_severities ORDER BY value ASC');
|
||||
}
|
||||
|
||||
export async function createSeverity(value) {
|
||||
const result = await query('INSERT INTO admin_severities (value) VALUES (?)', [value]);
|
||||
return { id: Number(result.insertId), value };
|
||||
}
|
||||
|
||||
export async function updateSeverity(id, value) {
|
||||
await query('UPDATE admin_severities SET value = ? WHERE id = ?', [value, id]);
|
||||
return { id, value };
|
||||
}
|
||||
|
||||
export async function deleteSeverity(id) {
|
||||
await query('DELETE FROM admin_severities WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* STATUSES
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export async function listStatuses() {
|
||||
const rows = await query('SELECT id, value, require_handled_by, require_comment FROM admin_statuses ORDER BY value ASC');
|
||||
return rows.map(r => ({ id: r.id, value: r.value, requireHandledBy: !!r.require_handled_by, requireComment: !!r.require_comment }));
|
||||
}
|
||||
|
||||
export async function createStatus(value, requireHandledBy = false, requireComment = false) {
|
||||
const result = await query('INSERT INTO admin_statuses (value, require_handled_by, require_comment) VALUES (?, ?, ?)', [value, requireHandledBy ? 1 : 0, requireComment ? 1 : 0]);
|
||||
return { id: Number(result.insertId), value, requireHandledBy, requireComment };
|
||||
}
|
||||
|
||||
export async function updateStatus(id, value, requireHandledBy = false, requireComment = false) {
|
||||
await query('UPDATE admin_statuses SET value = ?, require_handled_by = ?, require_comment = ? WHERE id = ?', [value, requireHandledBy ? 1 : 0, requireComment ? 1 : 0, id]);
|
||||
return { id, value, requireHandledBy, requireComment };
|
||||
}
|
||||
|
||||
export async function deleteStatus(id) {
|
||||
await query('DELETE FROM admin_statuses WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* HANDLED BY
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export async function listHandledBy() {
|
||||
return query('SELECT id, value FROM admin_handled_by ORDER BY value ASC');
|
||||
}
|
||||
|
||||
export async function createHandledBy(value) {
|
||||
const result = await query('INSERT INTO admin_handled_by (value) VALUES (?)', [value]);
|
||||
return { id: Number(result.insertId), value };
|
||||
}
|
||||
|
||||
export async function updateHandledBy(id, value) {
|
||||
await query('UPDATE admin_handled_by SET value = ? WHERE id = ?', [value, id]);
|
||||
return { id, value };
|
||||
}
|
||||
|
||||
export async function deleteHandledBy(id) {
|
||||
await query('DELETE FROM admin_handled_by WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* PROJECTS
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export async function listProjects() {
|
||||
return query('SELECT id, value FROM admin_projects ORDER BY value ASC');
|
||||
}
|
||||
|
||||
export async function createProject(value) {
|
||||
const result = await query('INSERT INTO admin_projects (value) VALUES (?)', [value]);
|
||||
return { id: Number(result.insertId), value };
|
||||
}
|
||||
|
||||
export async function updateProject(id, value) {
|
||||
await query('UPDATE admin_projects SET value = ? WHERE id = ?', [value, id]);
|
||||
return { id, value };
|
||||
}
|
||||
|
||||
export async function deleteProject(id) {
|
||||
await query('DELETE FROM admin_projects WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* PROCESSES
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export async function listProcesses() {
|
||||
return query('SELECT id, value, project_id AS projectId FROM admin_processes ORDER BY value ASC');
|
||||
}
|
||||
|
||||
export async function createProcess(value, projectId) {
|
||||
const result = await query('INSERT INTO admin_processes (value, project_id) VALUES (?, ?)', [value, projectId]);
|
||||
return { id: Number(result.insertId), value, projectId };
|
||||
}
|
||||
|
||||
export async function updateProcess(id, value, projectId) {
|
||||
await query('UPDATE admin_processes SET value = ?, project_id = ? WHERE id = ?', [value, projectId, id]);
|
||||
return { id, value, projectId };
|
||||
}
|
||||
|
||||
export async function deleteProcess(id) {
|
||||
await query('DELETE FROM admin_processes WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* USERS
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export async function listUsers() {
|
||||
return query(`
|
||||
SELECT id, email, password_hash AS password, name, family_name AS familyName, company, role
|
||||
FROM admin_users ORDER BY name ASC
|
||||
`);
|
||||
}
|
||||
|
||||
export async function createUser(data) {
|
||||
const result = await query(
|
||||
'INSERT INTO admin_users (email, password_hash, name, family_name, company, role) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[data.email, data.password || '', data.name, data.familyName, data.company || '', data.role]
|
||||
);
|
||||
return { id: Number(result.insertId), ...data };
|
||||
}
|
||||
|
||||
export async function updateUser(id, data) {
|
||||
await query(
|
||||
'UPDATE admin_users SET email = ?, password_hash = ?, name = ?, family_name = ?, company = ?, role = ? WHERE id = ?',
|
||||
[data.email, data.password || '', data.name, data.familyName, data.company || '', data.role, id]
|
||||
);
|
||||
return { id, ...data };
|
||||
}
|
||||
|
||||
export async function deleteUser(id) {
|
||||
await query('DELETE FROM admin_users WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* SITES
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export async function listSites() {
|
||||
return query(`
|
||||
SELECT id, site_code AS siteCode, host, obe_site_code AS obeSiteCode, pxs_site_code AS pxsSiteCode
|
||||
FROM admin_sites ORDER BY site_code ASC
|
||||
`);
|
||||
}
|
||||
|
||||
export async function createSite(data) {
|
||||
const result = await query(
|
||||
'INSERT INTO admin_sites (site_code, host, obe_site_code, pxs_site_code) VALUES (?, ?, ?, ?)',
|
||||
[data.siteCode, data.host || '', data.obeSiteCode || '', data.pxsSiteCode || '']
|
||||
);
|
||||
return { id: Number(result.insertId), ...data };
|
||||
}
|
||||
|
||||
export async function updateSite(id, data) {
|
||||
await query(
|
||||
'UPDATE admin_sites SET site_code = ?, host = ?, obe_site_code = ?, pxs_site_code = ? WHERE id = ?',
|
||||
[data.siteCode, data.host || '', data.obeSiteCode || '', data.pxsSiteCode || '', id]
|
||||
);
|
||||
return { id, ...data };
|
||||
}
|
||||
|
||||
export async function deleteSite(id) {
|
||||
await query('DELETE FROM admin_sites WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* CL RECORDS
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export async function listClRecords() {
|
||||
const records = await query(`
|
||||
SELECT id, sort_order AS sort, category, sub_category AS subCategory, severity,
|
||||
image_required AS imageRequired, description_en AS descriptionEN,
|
||||
description_fr AS descriptionFR, description_nl AS descriptionNL
|
||||
FROM admin_cl_records ORDER BY sort_order ASC
|
||||
`);
|
||||
/* Convert tinyint to boolean */
|
||||
for (const r of records) r.imageRequired = !!r.imageRequired;
|
||||
return records;
|
||||
}
|
||||
|
||||
export async function createClRecord(data) {
|
||||
const result = await query(
|
||||
`INSERT INTO admin_cl_records (sort_order, category, sub_category, severity, image_required, description_en, description_fr, description_nl)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[data.sort, data.category || '', data.subCategory || '', data.severity || '',
|
||||
data.imageRequired ? 1 : 0, data.descriptionEN || '', data.descriptionFR || '', data.descriptionNL || '']
|
||||
);
|
||||
return { id: Number(result.insertId), ...data };
|
||||
}
|
||||
|
||||
export async function updateClRecord(id, data) {
|
||||
await query(
|
||||
`UPDATE admin_cl_records SET sort_order = ?, category = ?, sub_category = ?, severity = ?,
|
||||
image_required = ?, description_en = ?, description_fr = ?, description_nl = ? WHERE id = ?`,
|
||||
[data.sort, data.category || '', data.subCategory || '', data.severity || '',
|
||||
data.imageRequired ? 1 : 0, data.descriptionEN || '', data.descriptionFR || '', data.descriptionNL || '', id]
|
||||
);
|
||||
return { id, ...data };
|
||||
}
|
||||
|
||||
export async function deleteClRecord(id) {
|
||||
await query('DELETE FROM admin_cl_records WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* CL TEMPLATES
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export async function listClTemplates() {
|
||||
const templates = await query(`
|
||||
SELECT id, name, scope, version, valid_from AS validFrom, valid_till AS validTill
|
||||
FROM admin_cl_templates ORDER BY name ASC
|
||||
`);
|
||||
|
||||
/* Convert dates to yyyy-MM-dd and attach recordIds to each template */
|
||||
for (const tpl of templates) {
|
||||
if (tpl.validFrom instanceof Date) tpl.validFrom = tpl.validFrom.toISOString().slice(0, 10);
|
||||
if (tpl.validTill instanceof Date) tpl.validTill = tpl.validTill.toISOString().slice(0, 10);
|
||||
const rows = await query(
|
||||
'SELECT record_id AS recordId FROM admin_cl_template_records WHERE template_id = ?', [tpl.id]
|
||||
);
|
||||
tpl.recordIds = rows.map((r) => r.recordId);
|
||||
}
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
export async function createClTemplate(data) {
|
||||
const result = await query(
|
||||
'INSERT INTO admin_cl_templates (name, scope, version, valid_from, valid_till) VALUES (?, ?, ?, ?, ?)',
|
||||
[data.name, data.scope || '', data.version || '', data.validFrom || null, data.validTill || null]
|
||||
);
|
||||
const id = Number(result.insertId);
|
||||
|
||||
if (data.recordIds?.length) {
|
||||
const values = data.recordIds.map((rid) => [id, rid]);
|
||||
await query(
|
||||
'INSERT INTO admin_cl_template_records (template_id, record_id) VALUES ' +
|
||||
values.map(() => '(?, ?)').join(', '),
|
||||
values.flat()
|
||||
);
|
||||
}
|
||||
|
||||
return { id, ...data };
|
||||
}
|
||||
|
||||
export async function updateClTemplate(id, data) {
|
||||
await query(
|
||||
'UPDATE admin_cl_templates SET name = ?, scope = ?, version = ?, valid_from = ?, valid_till = ? WHERE id = ?',
|
||||
[data.name, data.scope || '', data.version || '', data.validFrom || null, data.validTill || null, id]
|
||||
);
|
||||
|
||||
/* Replace record associations */
|
||||
await query('DELETE FROM admin_cl_template_records WHERE template_id = ?', [id]);
|
||||
if (data.recordIds?.length) {
|
||||
const values = data.recordIds.map((rid) => [id, rid]);
|
||||
await query(
|
||||
'INSERT INTO admin_cl_template_records (template_id, record_id) VALUES ' +
|
||||
values.map(() => '(?, ?)').join(', '),
|
||||
values.flat()
|
||||
);
|
||||
}
|
||||
|
||||
return { id, ...data };
|
||||
}
|
||||
|
||||
export async function deleteClTemplate(id) {
|
||||
await query('DELETE FROM admin_cl_templates WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* TASKS
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export async function listTasks() {
|
||||
return query(`
|
||||
SELECT id, site_id AS siteId, user_id AS userId, template_id AS templateId,
|
||||
project, process, status, created_at AS createdAt
|
||||
FROM admin_tasks ORDER BY created_at DESC
|
||||
`);
|
||||
}
|
||||
|
||||
export async function createTask(data) {
|
||||
const result = await query(
|
||||
'INSERT INTO admin_tasks (site_id, user_id, template_id, project, process, status) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[data.siteId, data.userId, data.templateId, data.project || '', data.process || '', data.status || 'pending']
|
||||
);
|
||||
return { id: Number(result.insertId), ...data };
|
||||
}
|
||||
|
||||
export async function updateTask(id, data) {
|
||||
await query(
|
||||
'UPDATE admin_tasks SET site_id = ?, user_id = ?, template_id = ?, project = ?, process = ?, status = ? WHERE id = ?',
|
||||
[data.siteId, data.userId, data.templateId, data.project || '', data.process || '', data.status || 'pending', id]
|
||||
);
|
||||
return { id, ...data };
|
||||
}
|
||||
|
||||
export async function deleteTask(id) {
|
||||
await query('DELETE FROM admin_tasks WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* BULK LOAD — returns all admin data in a single response
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
export async function loadAllAdminData() {
|
||||
const [categories, subCategories, severities, statuses, handledBy, projects, processes, users, sites, clRecords, clTemplates, tasks] =
|
||||
await Promise.all([
|
||||
listCategories(),
|
||||
listSubCategories(),
|
||||
listSeverities(),
|
||||
listStatuses(),
|
||||
listHandledBy(),
|
||||
listProjects(),
|
||||
listProcesses(),
|
||||
listUsers(),
|
||||
listSites(),
|
||||
listClRecords(),
|
||||
listClTemplates(),
|
||||
listTasks()
|
||||
]);
|
||||
|
||||
return {
|
||||
templateSettings: { categories, subCategories, severities, statuses, handledBy },
|
||||
taskSettings: { projects, processes },
|
||||
users,
|
||||
sites,
|
||||
clRecords,
|
||||
clTemplates,
|
||||
tasks
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Authentication service for basic PoC login.
|
||||
*
|
||||
* Provides simple username/password verification:
|
||||
* - Admin: credentials stored in admin_credentials table
|
||||
* - User: email/password stored in admin_users table
|
||||
*
|
||||
* Note: This is a proof-of-concept implementation without advanced security
|
||||
* features like password hashing, rate limiting, or JWT tokens.
|
||||
*/
|
||||
|
||||
import { query } from '../db/pool.js';
|
||||
|
||||
/**
|
||||
* Verify admin credentials against admin_credentials table.
|
||||
* @param {string} username - Admin username
|
||||
* @param {string} password - Admin password (plain text for PoC)
|
||||
* @returns {Promise<{valid: boolean, admin?: object}>}
|
||||
*/
|
||||
export async function verifyAdminCredentials(username, password) {
|
||||
const rows = await query(
|
||||
'SELECT id, username FROM admin_credentials WHERE username = ? AND password = ? LIMIT 1',
|
||||
[username, password]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
admin: { id: rows[0].id, username: rows[0].username, role: 'admin' }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user credentials against admin_users table.
|
||||
* @param {string} email - User email
|
||||
* @param {string} password - User password (stored in password_hash column)
|
||||
* @returns {Promise<{valid: boolean, user?: object}>}
|
||||
*/
|
||||
export async function verifyUserCredentials(email, password) {
|
||||
const rows = await query(
|
||||
`SELECT id, email, name, family_name AS familyName, company, role
|
||||
FROM admin_users
|
||||
WHERE email = ? AND password_hash = ? LIMIT 1`,
|
||||
[email, password]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
const user = rows[0];
|
||||
return {
|
||||
valid: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
familyName: user.familyName,
|
||||
company: user.company,
|
||||
role: user.role
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a simple session token (for PoC, just a random string).
|
||||
* In production, use proper JWT or secure session management.
|
||||
*/
|
||||
export function generateSessionToken() {
|
||||
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
/* In-memory session store (for PoC only - not suitable for production) */
|
||||
const sessions = new Map();
|
||||
|
||||
/**
|
||||
* Create a session for an authenticated user/admin.
|
||||
*/
|
||||
export function createSession(token, data) {
|
||||
sessions.set(token, { ...data, createdAt: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session data by token.
|
||||
*/
|
||||
export function getSession(token) {
|
||||
return sessions.get(token) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a session (logout).
|
||||
*/
|
||||
export function removeSession(token) {
|
||||
sessions.delete(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session is still valid (exists and not expired).
|
||||
* Sessions expire after 24 hours for PoC.
|
||||
*/
|
||||
export function validateSession(token) {
|
||||
const session = sessions.get(token);
|
||||
if (!session) return null;
|
||||
|
||||
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
|
||||
if (Date.now() - session.createdAt > maxAge) {
|
||||
sessions.delete(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
@@ -121,3 +121,31 @@ export async function getAppConfig() {
|
||||
value: parseJsonColumn(row.configValue)
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAppConfigValue(key) {
|
||||
const rows = await query(
|
||||
`SELECT config_value_json AS configValue FROM app_config WHERE config_key = ? LIMIT 1`,
|
||||
[key]
|
||||
);
|
||||
|
||||
return rows.length ? parseJsonColumn(rows[0].configValue) : null;
|
||||
}
|
||||
|
||||
export async function upsertAppConfig(key, value) {
|
||||
/*
|
||||
* Upsert a single app_config row. Used by the admin module to persist entity
|
||||
* data (users, sites, CL records, etc.) that was previously localStorage-only.
|
||||
*/
|
||||
await query(
|
||||
`
|
||||
INSERT INTO app_config (config_key, config_value_json)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
config_value_json = VALUES(config_value_json),
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[key, JSON.stringify(value)]
|
||||
);
|
||||
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ import { query } from '../db/pool.js';
|
||||
import { parseJsonColumn } from '../utils/json.js';
|
||||
|
||||
/*
|
||||
* The report service handles server-side storage of submitted reports. In
|
||||
* phase 1, reports are created locally in the browser and only uploaded when
|
||||
* the operator explicitly submits. This keeps the offline-first workflow intact
|
||||
* while giving the backend a durable copy for review, export, or archival.
|
||||
* The report service handles server-side storage of submitted reports.
|
||||
* Images are stored as BLOBs in the report_images table alongside metadata.
|
||||
*/
|
||||
|
||||
export async function submitReport(report) {
|
||||
/* Strip image dataUrls from answers before storing in JSON column */
|
||||
const answersForJson = stripImagesFromAnswers(report.answers);
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO reports (report_uuid, report_number, template_code, template_version, status, answers_json, submitted_at)
|
||||
@@ -25,10 +26,15 @@ export async function submitReport(report) {
|
||||
report.templateCode,
|
||||
report.templateVersion,
|
||||
report.status,
|
||||
JSON.stringify(report.answers)
|
||||
JSON.stringify(answersForJson)
|
||||
]
|
||||
);
|
||||
|
||||
/* Store images as BLOBs in DB */
|
||||
if (report.answers?.records) {
|
||||
await storeReportImages(report.id, report.answers.records);
|
||||
}
|
||||
|
||||
return getReport(report.id);
|
||||
}
|
||||
|
||||
@@ -106,3 +112,122 @@ function mapReportRow(row) {
|
||||
updatedAt: row.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* Image storage helpers
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* Strips dataUrl from image objects in answers so the JSON column stays lean.
|
||||
* The full image data is stored separately in report_images.
|
||||
*/
|
||||
function stripImagesFromAnswers(answers) {
|
||||
if (!answers?.records) return answers;
|
||||
const clean = { ...answers, records: {} };
|
||||
for (const [recId, rd] of Object.entries(answers.records)) {
|
||||
clean.records[recId] = {
|
||||
...rd,
|
||||
images: (rd.images || []).map(img => ({
|
||||
name: img.name,
|
||||
size: img.size,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
exif: img.exif || null
|
||||
}))
|
||||
};
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores image binary data as BLOBs in the report_images table.
|
||||
* Replaces existing images for the report on re-submit.
|
||||
*/
|
||||
async function storeReportImages(reportUuid, records) {
|
||||
/* Clear existing images for this report to avoid duplicates */
|
||||
await query('DELETE FROM report_images WHERE report_uuid = ?', [reportUuid]);
|
||||
|
||||
for (const [recId, rd] of Object.entries(records)) {
|
||||
if (!rd.images?.length) continue;
|
||||
for (let i = 0; i < rd.images.length; i++) {
|
||||
const img = rd.images[i];
|
||||
if (!img.dataUrl) continue;
|
||||
|
||||
/* Convert base64 dataUrl to Buffer */
|
||||
const matches = img.dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!matches) continue;
|
||||
const mimeType = matches[1];
|
||||
const buffer = Buffer.from(matches[2], 'base64');
|
||||
|
||||
const fileName = img.name || `image_${i}.jpg`;
|
||||
|
||||
await query(
|
||||
`INSERT INTO report_images (report_uuid, record_id, image_index, file_name, file_size, mime_type, width_px, height_px, exif_json, image_data)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
reportUuid,
|
||||
recId,
|
||||
i,
|
||||
fileName,
|
||||
img.size || buffer.length,
|
||||
mimeType,
|
||||
img.width || null,
|
||||
img.height || null,
|
||||
img.exif ? JSON.stringify(img.exif) : null,
|
||||
buffer
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all images for a given report, grouped by record ID.
|
||||
* Returns base64 dataUrls constructed from the stored BLOBs.
|
||||
*/
|
||||
export async function getReportImages(reportUuid) {
|
||||
const rows = await query(
|
||||
`SELECT record_id AS recordId, image_index AS imageIndex, file_name AS fileName,
|
||||
file_size AS fileSize, mime_type AS mimeType, width_px AS widthPx,
|
||||
height_px AS heightPx, exif_json AS exifJson, image_data AS imageData
|
||||
FROM report_images
|
||||
WHERE report_uuid = ?
|
||||
ORDER BY record_id, image_index`,
|
||||
[reportUuid]
|
||||
);
|
||||
|
||||
const grouped = {};
|
||||
for (const row of rows) {
|
||||
if (!grouped[row.recordId]) grouped[row.recordId] = [];
|
||||
const base64 = row.imageData.toString('base64');
|
||||
grouped[row.recordId].push({
|
||||
index: row.imageIndex,
|
||||
name: row.fileName,
|
||||
size: row.fileSize,
|
||||
mimeType: row.mimeType,
|
||||
width: row.widthPx,
|
||||
height: row.heightPx,
|
||||
exif: parseJsonColumn(row.exifJson, null),
|
||||
dataUrl: `data:${row.mimeType};base64,${base64}`
|
||||
});
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a report and all its associated images from DB.
|
||||
*/
|
||||
export async function deleteReport(reportUuid) {
|
||||
/* CASCADE will remove report_images rows automatically */
|
||||
await query('DELETE FROM reports WHERE report_uuid = ?', [reportUuid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a specific image for a record in a report.
|
||||
*/
|
||||
export async function deleteReportImage(reportUuid, recordId, fileName) {
|
||||
await query(
|
||||
'DELETE FROM report_images WHERE report_uuid = ? AND record_id = ? AND file_name = ?',
|
||||
[reportUuid, recordId, fileName]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user