Tom Butcher f852e607f9
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
Added excel support.
2026-03-03 01:01:52 +00:00

290 lines
10 KiB
JavaScript

import config from '../../config.js';
import log4js from 'log4js';
import { getModelByName } from './model.js';
import { listObjectsOData } from '../../database/odata.js';
import { getFilter } from '../../utils.js';
import {
generateExcelTable,
createExcelTempToken,
getExcelTempParams,
incrementExcelTempRequestCount,
deleteExcelTempToken,
} from '../../database/excel.js';
const logger = log4js.getLogger('Excel');
logger.level = config.server.logLevel;
/**
* Flatten nested objects for Excel display.
* Objects become "key.subkey: value" or JSON string; arrays become comma-separated.
*/
function flattenForExcel(obj, prefix = '') {
if (obj === null || obj === undefined) return {};
if (typeof obj !== 'object') return { [prefix]: obj };
if (obj instanceof Date) return { [prefix]: obj };
if (Array.isArray(obj)) {
const str = obj
.map((v) => (v && typeof v === 'object' && !(v instanceof Date) ? JSON.stringify(v) : v))
.join(', ');
return { [prefix]: str };
}
const result = {};
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !(v instanceof Date) && !Array.isArray(v)) {
Object.assign(result, flattenForExcel(v, key));
} else {
result[key] = v;
}
}
return result;
}
/**
* Convert a row to flat key-value for Excel. Nested objects are flattened.
*/
function rowToFlat(row) {
const flat = {};
for (const [key, val] of Object.entries(row)) {
if (key.startsWith('@')) continue;
if (val !== null && typeof val === 'object' && !(val instanceof Date) && !Array.isArray(val)) {
Object.assign(flat, flattenForExcel(val, key));
} else {
flat[key] = val;
}
}
return flat;
}
/**
* Get allowed filter fields for Excel export (reuse OData logic).
*/
function getModelFilterFields(objectType) {
const base = ['_id'];
const byType = {
note: ['parent._id', 'noteType', 'user'],
notification: ['user'],
userNotifier: ['user', 'object', 'objectType'],
printer: ['host'],
job: ['printer', 'gcodeFile'],
subJob: ['job'],
filamentStock: ['filament'],
partStock: ['part'],
purchaseOrder: ['vendor'],
orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'shipment._id'],
shipment: ['order._id', 'orderType', 'courierService._id'],
stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
stockAudit: ['filamentStock._id', 'partStock._id'],
documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'],
documentTemplate: ['parent._id', 'documentSize._id'],
salesOrder: ['client'],
invoice: ['to._id', 'from._id', 'order._id', 'orderType'],
auditLog: ['parent._id', 'parentType', 'owner._id', 'ownerType'],
appPassword: ['name', 'user', 'active'],
};
const extra = byType[objectType] || [];
return [...base, ...extra];
}
function parseOrderBy(orderby) {
if (!orderby || typeof orderby !== 'string') {
return { sort: 'createdAt', order: 'ascend' };
}
const trimmed = orderby.trim();
const parts = trimmed.split(/\s+/);
const sort = parts[0] || 'createdAt';
const dir = (parts[1] || 'asc').toLowerCase();
const order = dir === 'desc' ? 'descend' : 'ascend';
return { sort, order };
}
/**
* Generate Excel file for the given object type.
* @param {Object} options
* @param {string} options.objectType - Model type (e.g. 'appPassword', 'user')
* @param {Object} [options.filter] - Filter object
* @param {string} [options.sort] - Sort field
* @param {string} [options.order] - 'ascend' | 'descend'
* @param {number} [options.limit=10000] - Max rows to export
* @returns {Promise<{ buffer: Buffer, error?: Object }>}
*/
export async function exportToExcel({ objectType, filter = {}, sort, order, limit = 10000 }) {
logger.info('[Excel Export] Starting', { objectType, filter, sort, order });
const entry = getModelByName(objectType);
if (!entry?.model) {
logger.warn('[Excel Export] Unknown object type:', objectType);
return { error: { message: `Unknown object type: ${objectType}` }, code: 404 };
}
const orderbyStr = sort ? `${sort} ${order === 'descend' ? 'desc' : 'asc'}` : undefined;
const { sort: sortField, order: orderDir } = parseOrderBy(orderbyStr);
const result = await listObjectsOData({
model: entry.model,
populate: [],
page: 1,
limit,
filter,
sort: sortField,
order: orderDir,
pagination: true,
project: undefined,
count: false,
});
if (result?.error) {
logger.error('[Excel Export] listObjectsOData error:', result.error);
return { error: result.error, code: result.code || 500 };
}
const rows = result?.value || [];
logger.info('[Excel Export] Rows fetched', { rowCount: rows.length });
const flatRows = rows.map(rowToFlat);
const allKeys = new Set();
flatRows.forEach((r) => Object.keys(r).forEach((k) => allKeys.add(k)));
const columnOrder = Array.from(allKeys).sort();
let buffer;
try {
buffer = await generateExcelTable(flatRows, {
sheetName: entry.model.modelName || objectType,
columnOrder,
});
logger.info('[Excel Export] Buffer generated', { bufferLength: buffer?.length });
} catch (err) {
logger.error('[Excel Export] generateExcelTable threw:', err?.message, err?.stack);
return { error: { message: err.message || 'Failed to generate Excel' }, code: 500 };
}
return { buffer };
}
/**
* Route handler for GET /odata/:objectType/excel
*/
export const excelExportRouteHandler = async (req, res) => {
const objectType = req.params.objectType;
const allowedFilters = getModelFilterFields(objectType);
const filter = getFilter(req.query, allowedFilters);
const { sort, order } = parseOrderBy(req.query.$orderby);
const result = await exportToExcel({
objectType,
filter,
sort,
order,
});
if (result.error) {
return res.status(result.code || 500).json(result.error);
}
const filename = `${objectType}-export-${new Date().toISOString().slice(0, 10)}.xlsx`;
res.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.set('Content-Disposition', `attachment; filename="${filename}"`);
res.send(result.buffer);
};
/**
* Route handler for POST /excel/:objectType/open - creates a temporary URL for opening in Excel.
* Stores export params in Redis; spreadsheet is generated when Excel fetches the temp URL.
* Returns { url } - an https URL Excel can fetch (no auth required, one-time use, 5 min expiry).
*/
export const excelOpenRouteHandler = async (req, res) => {
try {
const objectType = req.params.objectType;
logger.info('[Excel Open] POST received', { objectType, query: req.query });
const allowedFilters = getModelFilterFields(objectType);
const filter = getFilter(req.query, allowedFilters);
const { sort, order } = parseOrderBy(req.query.$orderby);
const entry = getModelByName(objectType);
if (!entry?.model) {
logger.warn('[Excel Open] Unknown object type:', objectType);
return res.status(404).json({ message: `Unknown object type: ${objectType}` });
}
const params = { objectType, filter, sort, order };
logger.info('[Excel Open] Creating temp token with params:', JSON.stringify(params));
const { token, url } = await createExcelTempToken(params);
if (!url) {
logger.error('[Excel Open] createExcelTempToken returned no url');
return res.status(500).json({ message: 'Failed to create temporary URL' });
}
logger.info('[Excel Open] Temp URL created', { objectType, token, url });
res.json({ url });
} catch (err) {
logger.error('[Excel Open] Route error:', err?.message, err?.stack);
res.status(500).json({ message: err.message || 'Failed to create Excel URL' });
}
};
/**
* Route handler for GET /excel/temp/:token - generates and serves temporary Excel file (no auth).
* Retrieves export params from Redis, generates spreadsheet on demand.
* Token allows 2 requests before deletion; expires after 5 minutes.
*/
export const excelTempRouteHandler = async (req, res) => {
const tokenParam = req.params.token || '';
const token = tokenParam.endsWith('.xlsx') ? tokenParam.slice(0, -5) : tokenParam;
logger.info('[Excel Temp] GET received', {
tokenParam,
token,
userAgent: req.get('User-Agent'),
referer: req.get('Referer'),
});
const params = await getExcelTempParams(token);
if (!params) {
logger.error('[Excel Temp] Token not found or expired', { token, tokenParam });
return res.status(404).json({ message: 'Link expired or invalid' });
}
logger.info('[Excel Temp] Params retrieved from Redis', { objectType: params.objectType });
const result = await exportToExcel({
objectType: params.objectType,
filter: params.filter || {},
sort: params.sort,
order: params.order,
});
if (result.error) {
logger.error('[Excel Temp] Export failed', { error: result.error, code: result.code });
return res.status(result.code || 500).json(result.error);
}
const buffer = Buffer.isBuffer(result.buffer) ? result.buffer : Buffer.from(result.buffer);
const filename = `${token}.xlsx`;
logger.info('[Excel Temp] Sending buffer', { bufferLength: buffer.length });
// Strong ETag based on buffer length + timestamp
const strongETag = `"${buffer.length.toString(16)}-${Date.now()}"`;
res.setHeader('Content-Length', buffer.length);
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
res.setHeader('Cache-Control', 'private, max-age=300');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('ETag', strongETag);
// Remove headers that may break Excel
res.removeHeader('Vary');
res.removeHeader('Access-Control-Allow-Credentials');
res.removeHeader('X-Powered-By');
res.end(buffer);
if (req.method === 'GET') {
const requestCount = await incrementExcelTempRequestCount(token);
if (requestCount !== null && requestCount >= 2) {
await deleteExcelTempToken(token);
}
}
};