All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
290 lines
10 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
};
|