Working version before modification.

This commit is contained in:
Stan
2026-04-20 21:04:54 +02:00
parent 28d167f11f
commit e7127f3215
30 changed files with 7046 additions and 1201 deletions
+388
View File
@@ -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
};
}
+115
View File
@@ -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;
}
+28
View File
@@ -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 };
}
+130 -5
View File
@@ -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]
);
}