234 lines
6.8 KiB
JavaScript
234 lines
6.8 KiB
JavaScript
import { query } from '../db/pool.js';
|
|
import { parseJsonColumn } from '../utils/json.js';
|
|
|
|
/*
|
|
* 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)
|
|
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(answersForJson)
|
|
]
|
|
);
|
|
|
|
/* Store images as BLOBs in DB */
|
|
if (report.answers?.records) {
|
|
await storeReportImages(report.id, report.answers.records);
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════════════
|
|
* 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]
|
|
);
|
|
}
|