stage 1
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user