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); } } };