This commit is contained in:
Stan
2026-04-19 21:14:16 +02:00
parent 0c74a75126
commit 28d167f11f
42 changed files with 5681 additions and 55 deletions
+75
View File
@@ -0,0 +1,75 @@
import { query } from '../db/pool.js';
/*
* The audit service records every administrative mutation so the team can trace
* when configuration changed and what the previous value was. Each row captures
* the entity type (e.g. "image_rules"), the entity identifier, the action name,
* and JSON snapshots of the old and new values.
*/
export async function logAuditEvent({ entityType, entityCode, action, oldValue = null, newValue = null }) {
await query(
`
INSERT INTO audit_log (entity_type, entity_code, action, old_value_json, new_value_json)
VALUES (?, ?, ?, ?, ?)
`,
[
entityType,
entityCode,
action,
oldValue ? JSON.stringify(oldValue) : null,
newValue ? JSON.stringify(newValue) : null
]
);
}
export async function getAuditLog({ entityType, entityCode, limit = 50 } = {}) {
let sql = 'SELECT id, entity_type, entity_code, action, old_value_json, new_value_json, created_at FROM audit_log';
const params = [];
const clauses = [];
if (entityType) {
clauses.push('entity_type = ?');
params.push(entityType);
}
if (entityCode) {
clauses.push('entity_code = ?');
params.push(entityCode);
}
if (clauses.length) {
sql += ` WHERE ${clauses.join(' AND ')}`;
}
sql += ' ORDER BY id DESC LIMIT ?';
params.push(limit);
const rows = await query(sql, params);
return rows.map((row) => ({
id: row.id,
entityType: row.entity_type,
entityCode: row.entity_code,
action: row.action,
oldValue: safeParseJson(row.old_value_json),
newValue: safeParseJson(row.new_value_json),
createdAt: row.created_at
}));
}
function safeParseJson(value) {
if (value == null) {
return null;
}
if (typeof value === 'object') {
return value;
}
try {
return JSON.parse(value);
} catch {
return null;
}
}
+70
View File
@@ -0,0 +1,70 @@
/*
* Simple in-memory LRU cache for read-heavy data such as templates and lookups.
* Each cache entry tracks its last-access timestamp. When the cache exceeds the
* configured maximum size the least-recently-used entry is evicted automatically.
*
* This avoids hitting MariaDB on every request for data that changes rarely while
* keeping the implementation dependency-free.
*/
export function createCache({ maxEntries = 100, ttlMs = 5 * 60 * 1000 } = {}) {
const store = new Map();
function get(key) {
const entry = store.get(key);
if (!entry) {
return undefined;
}
if (Date.now() - entry.createdAt > ttlMs) {
store.delete(key);
return undefined;
}
entry.lastAccess = Date.now();
return entry.value;
}
function set(key, value) {
if (store.size >= maxEntries) {
evictLru();
}
store.set(key, { value, createdAt: Date.now(), lastAccess: Date.now() });
}
function invalidate(key) {
store.delete(key);
}
function clear() {
store.clear();
}
function evictLru() {
let oldestKey = null;
let oldestAccess = Infinity;
for (const [key, entry] of store) {
if (entry.lastAccess < oldestAccess) {
oldestAccess = entry.lastAccess;
oldestKey = key;
}
}
if (oldestKey !== null) {
store.delete(oldestKey);
}
}
return { get, set, invalidate, clear };
}
/*
* Shared cache instances. Templates and lookups change rarely enough that a
* five-minute TTL is practical during normal operations. The admin invalidates
* the relevant cache key on write so changes appear immediately.
*/
export const templateCache = createCache({ maxEntries: 50, ttlMs: 5 * 60 * 1000 });
export const lookupCache = createCache({ maxEntries: 50, ttlMs: 5 * 60 * 1000 });
export const configCache = createCache({ maxEntries: 20, ttlMs: 2 * 60 * 1000 });
+54
View File
@@ -1,6 +1,12 @@
import { query } from '../db/pool.js';
import { parseJsonColumn } from '../utils/json.js';
/*
* Phase 1 keeps exactly one active image rule set. The frontend asks only for
* the active rule because that matches the current business need: operators use
* the latest centrally managed policy, while drafts themselves do not yet store
* an immutable copy of the rule configuration.
*/
export async function getImageRules() {
const rows = await query(
`
@@ -31,6 +37,49 @@ export async function getImageRules() {
};
}
export async function updateImageRules(nextImageRules) {
const currentRules = await getImageRules();
if (!currentRules) {
return null;
}
/*
* The PoC updates the currently active rule in place instead of creating a new
* version row. That keeps the administrator flow small and easy to reason
* about. If later phases need audit history, this is the point where versioned
* writes or soft-retired rows should be introduced.
*/
await query(
`
UPDATE image_rules
SET
name = ?,
allowed_mime_types_json = ?,
max_file_size_bytes = ?,
max_width_px = ?,
max_height_px = ?,
jpeg_quality = ?,
oversize_behavior = ?,
max_attachments_per_field = ?
WHERE code = ?
`,
[
nextImageRules.name,
JSON.stringify(nextImageRules.allowedMimeTypes),
nextImageRules.maxFileSizeBytes,
nextImageRules.maxWidthPx,
nextImageRules.maxHeightPx,
nextImageRules.jpegQuality,
nextImageRules.oversizeBehavior,
nextImageRules.maxAttachmentsPerField,
currentRules.code
]
);
return getImageRules();
}
export async function getExportProfile() {
const rows = await query(
`
@@ -52,6 +101,11 @@ export async function getExportProfile() {
}
export async function getAppConfig() {
/*
* Config values are stored as JSON so the frontend can receive structured data
* without a separate table for every small setting. The helper converts JSON
* strings into usable objects and arrays before returning them.
*/
const rows = await query(
`
SELECT
+14
View File
@@ -1,6 +1,11 @@
import { query } from '../db/pool.js';
function groupLookups(rows) {
/*
* SQL returns one row per lookup value, but the frontend wants a grouped shape
* where each lookup code owns an array of options. Building that structure here
* keeps the API contract friendly for dynamic form rendering.
*/
const lookups = new Map();
for (const row of rows) {
@@ -26,6 +31,10 @@ function groupLookups(rows) {
}
export async function listLookups() {
/*
* Active lookup values are sorted in SQL so the client receives them in display
* order without additional sorting logic in the browser.
*/
const rows = await query(
`
SELECT
@@ -48,6 +57,11 @@ export async function listLookups() {
}
export async function getLookup(lookupCode) {
/*
* The single-lookup query reuses the same grouping logic as the bulk endpoint,
* which keeps the returned shape consistent regardless of how the frontend or a
* debugging tool chooses to retrieve lookup data.
*/
const rows = await query(
`
SELECT
+108
View File
@@ -0,0 +1,108 @@
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.
*/
export async function submitReport(report) {
await query(
`
INSERT INTO reports (report_uuid, report_number, template_code, template_version, status, answers_json, submitted_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
status = VALUES(status),
answers_json = VALUES(answers_json),
submitted_at = VALUES(submitted_at),
updated_at = NOW()
`,
[
report.id,
report.reportNumber,
report.templateCode,
report.templateVersion,
report.status,
JSON.stringify(report.answers)
]
);
return getReport(report.id);
}
export async function getReport(reportUuid) {
const rows = await query(
`
SELECT
report_uuid AS reportUuid,
report_number AS reportNumber,
template_code AS templateCode,
template_version AS templateVersion,
status,
answers_json AS answersJson,
submitted_at AS submittedAt,
created_at AS createdAt,
updated_at AS updatedAt
FROM reports
WHERE report_uuid = ?
LIMIT 1
`,
[reportUuid]
);
return rows.length ? mapReportRow(rows[0]) : null;
}
export async function listReports({ status, templateCode, limit = 100, offset = 0 } = {}) {
let sql = `
SELECT
report_uuid AS reportUuid,
report_number AS reportNumber,
template_code AS templateCode,
template_version AS templateVersion,
status,
answers_json AS answersJson,
submitted_at AS submittedAt,
created_at AS createdAt,
updated_at AS updatedAt
FROM reports
`;
const params = [];
const clauses = [];
if (status) {
clauses.push('status = ?');
params.push(status);
}
if (templateCode) {
clauses.push('template_code = ?');
params.push(templateCode);
}
if (clauses.length) {
sql += ` WHERE ${clauses.join(' AND ')}`;
}
sql += ' ORDER BY updated_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const rows = await query(sql, params);
return rows.map(mapReportRow);
}
function mapReportRow(row) {
return {
id: row.reportUuid,
reportNumber: row.reportNumber,
templateCode: row.templateCode,
templateVersion: row.templateVersion,
status: row.status,
answers: parseJsonColumn(row.answersJson, {}),
submittedAt: row.submittedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt
};
}
+118
View File
@@ -2,6 +2,11 @@ import { query } from '../db/pool.js';
import { parseJsonColumn } from '../utils/json.js';
function mapTemplateRow(row) {
/*
* Template definitions are stored as JSON in MariaDB, but the frontend expects
* them as native objects. The mapper centralizes that translation and keeps the
* route handlers free from storage-specific details.
*/
return {
code: row.code,
name: row.name,
@@ -14,6 +19,11 @@ function mapTemplateRow(row) {
}
export async function listTemplates() {
/*
* Only active versions are listed for new report creation. Retired or draft
* versions may still exist in the database, but they should not appear in the
* main template picker used by operators.
*/
const rows = await query(
`
SELECT
@@ -40,7 +50,40 @@ export async function listTemplates() {
}));
}
export async function getAllActiveTemplates() {
/*
* Batch endpoint: returns every active template with its full JSON definition
* in a single query. This replaces the N+1 pattern where the client listed
* templates then fetched each definition individually — cutting initial sync
* from N+1 round-trips to one.
*/
const rows = await query(
`
SELECT
t.code,
t.name,
t.description,
tv.version_number AS versionNumber,
tv.status,
tv.published_at AS publishedAt,
tv.definition_json AS definitionJson
FROM templates t
INNER JOIN template_versions tv
ON tv.template_id = t.id
AND tv.status = 'active'
ORDER BY t.name ASC
`
);
return rows.map(mapTemplateRow);
}
export async function getActiveTemplate(templateCode) {
/*
* This query returns the single currently active version for a given template
* code. That matches the business rule that new drafts should always start from
* the newest active template definition.
*/
const rows = await query(
`
SELECT
@@ -65,6 +108,11 @@ export async function getActiveTemplate(templateCode) {
}
export async function getTemplateVersion(templateCode, versionNumber) {
/*
* Version-specific reads are intentionally separate from active-template reads
* so draft reopening can be explicit and reliable, even if the active version
* changes later.
*/
const rows = await query(
`
SELECT
@@ -87,3 +135,73 @@ export async function getTemplateVersion(templateCode, versionNumber) {
return rows.length ? mapTemplateRow(rows[0]) : null;
}
export async function listTemplateVersions(templateCode) {
/*
* Returns all versions of a template so the admin workspace can show the full
* version history and allow publishing a different version.
*/
const rows = await query(
`
SELECT
t.code,
t.name,
tv.version_number AS versionNumber,
tv.status,
tv.published_at AS publishedAt
FROM templates t
INNER JOIN template_versions tv
ON tv.template_id = t.id
WHERE t.code = ?
ORDER BY tv.version_number DESC
`,
[templateCode]
);
return rows.map((row) => ({
code: row.code,
name: row.name,
version: row.versionNumber,
status: row.status,
publishedAt: row.publishedAt
}));
}
export async function publishTemplateVersion(templateCode, versionNumber) {
/*
* Publishing retires the currently active version and activates the requested
* one. Both updates run sequentially. In production this would be wrapped in a
* database transaction; the PoC trades strict atomicity for simplicity.
*/
const templateRows = await query(
'SELECT id FROM templates WHERE code = ?',
[templateCode]
);
if (!templateRows.length) {
return null;
}
const templateId = templateRows[0].id;
const versionRows = await query(
'SELECT id FROM template_versions WHERE template_id = ? AND version_number = ?',
[templateId, versionNumber]
);
if (!versionRows.length) {
return null;
}
await query(
"UPDATE template_versions SET status = 'retired' WHERE template_id = ? AND status = 'active'",
[templateId]
);
await query(
"UPDATE template_versions SET status = 'active', published_at = NOW() WHERE id = ?",
[versionRows[0].id]
);
return getActiveTemplate(templateCode);
}