Added excel support.
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-03-03 01:01:52 +00:00
parent fcaa5a1043
commit f852e607f9
7 changed files with 935 additions and 2 deletions

View File

@ -13,6 +13,7 @@
"canonical-json": "^0.2.0",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"exceljs": "^4.4.0",
"exifr": "^7.1.3",
"express": "^5.1.0",
"express-session": "^1.18.2",

463
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

160
src/database/excel.js Normal file
View File

@ -0,0 +1,160 @@
import { customAlphabet } from 'nanoid';
import log4js from 'log4js';
import ExcelJS from 'exceljs';
import config from '../config.js';
import { redisServer } from './redis.js';
const logger = log4js.getLogger('Excel');
const EXCEL_TEMP_KEY_PREFIX = 'excel:temp:';
const EXCEL_TEMP_TTL_SECONDS = 15; // 15 seconds
const excelNanoid = customAlphabet(
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
12
);
/**
* Create a temp token and store export params in Redis.
* @param {Object} params - { objectType, filter, sort, order }
* @returns {Promise<{ token: string, url: string }>}
*/
export async function createExcelTempToken(params) {
const baseUrl = config.app?.urlApi?.replace(/\/$/, '') || '';
if (!baseUrl) {
throw new Error('config.app.urlApi is not set; required for Excel temp URLs');
}
const objectType = params.objectType || 'Export';
const now = new Date();
const datetime =
now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0') +
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0');
const token = `${objectType}s-${datetime}-${excelNanoid()}`;
const key = EXCEL_TEMP_KEY_PREFIX + token;
const stored = { ...params, requestCount: 0 };
await redisServer.setKey(key, stored, EXCEL_TEMP_TTL_SECONDS);
logger.debug('Stored excel temp token in Redis:', key);
const url = `${baseUrl}/excel/temp/${token}.xlsx`;
return { token, url };
}
/**
* Get export params for a temp token (supports up to 2 requests; requestCount stored in Redis).
* @param {string} token
* @returns {Promise<Object|null>} { objectType, filter, sort, order } or null
*/
export async function getExcelTempParams(token) {
if (!token) return null;
const key = EXCEL_TEMP_KEY_PREFIX + token;
const params = await redisServer.getKey(key);
if (!params) {
logger.debug('Excel temp token not found in Redis:', key);
return null;
}
return params;
}
/**
* Convert a value to an Excel cell-friendly format.
* Primitives pass through; objects/arrays are stringified; dates are preserved.
*/
function toExcelValue(val) {
if (val === null || val === undefined) return null;
if (val instanceof Date) return val;
if (typeof val === 'number' || typeof val === 'boolean') return val;
if (typeof val === 'string') return val;
if (typeof val === 'object') {
if (Array.isArray(val)) return val.map(toExcelValue).join(', ');
return JSON.stringify(val);
}
return String(val);
}
/**
* Generate an Excel workbook from tabular data.
* @param {Array<Object>} data - Array of row objects (keys = column headers)
* @param {Object} options - Options
* @param {string} [options.sheetName='Export'] - Worksheet name
* @param {string[]} [options.columnOrder] - Optional column order (uses Object.keys of first row if not provided)
* @returns {Promise<Buffer>} Excel file as buffer
*/
export async function generateExcelTable(data, options = {}) {
const { sheetName = 'Export', columnOrder } = options;
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet(sheetName, {
views: [{ state: 'frozen', ySplit: 1 }],
});
if (!data || data.length === 0) {
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
const keys = columnOrder || Object.keys(data[0]).filter((k) => !k.startsWith('@'));
const colCount = keys.length;
const rowCount = data.length + 1;
const toColLetter = (n) => {
let s = '';
while (n >= 0) {
s = String.fromCharCode((n % 26) + 65) + s;
n = Math.floor(n / 26) - 1;
}
return s;
};
const endCol = toColLetter(colCount - 1);
const tableRows = data.map((row) => keys.map((key) => toExcelValue(row[key])));
worksheet.addTable({
name: 'DataTable',
ref: `A1:${endCol}${rowCount}`,
headerRow: true,
style: {
theme: 'TableStyleLight1',
showRowStripes: true,
},
columns: keys.map((key) => ({ name: key, filterButton: true })),
rows: tableRows,
});
// Auto-fit columns (approximate)
worksheet.columns.forEach((col, i) => {
let maxLen = keys[i]?.length || 10;
worksheet.eachRow({ includeEmpty: false }, (row) => {
const cell = row.getCell(i + 1);
const val = cell.value;
const len = val != null ? String(val).length : 0;
maxLen = Math.min(Math.max(maxLen, len), 50);
});
col.width = maxLen + 2;
});
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
/**
* Increment request count for a temp token. Returns new count or null if token not found.
* @param {string} token
* @returns {Promise<number|null>} New requestCount or null
*/
export async function incrementExcelTempRequestCount(token) {
if (!token) return null;
const key = EXCEL_TEMP_KEY_PREFIX + token;
const data = await redisServer.getKey(key);
if (!data) return null;
const requestCount = (data.requestCount ?? 0) + 1;
const updated = { ...data, requestCount };
await redisServer.setKey(key, updated, EXCEL_TEMP_TTL_SECONDS);
logger.debug('Incremented excel temp request count:', key, requestCount);
return requestCount;
}
export async function deleteExcelTempToken(token) {
const key = EXCEL_TEMP_KEY_PREFIX + token;
await redisServer.deleteKey(key);
logger.debug('Deleted excel temp token from Redis:', key);
}

View File

@ -3,6 +3,7 @@ import bodyParser from 'body-parser';
import cors from 'cors';
import config from './config.js';
import { dbConnect } from './database/mongo.js';
import { redisServer } from './database/redis.js';
import {
authRoutes,
userRoutes,
@ -44,6 +45,7 @@ import {
userNotifierRoutes,
notificationRoutes,
odataRoutes,
excelRoutes,
} from './routes/index.js';
import path from 'path';
import * as fs from 'fs';
@ -83,6 +85,9 @@ async function initializeApp() {
// Connect to database
await dbConnect();
// Connect to Redis (required for excel temp tokens, sessions, cache)
await redisServer.connect();
// Connect to NATS
await natsServer.connect();
@ -153,6 +158,7 @@ app.use('/notes', noteRoutes);
app.use('/usernotifiers', userNotifierRoutes);
app.use('/notifications', notificationRoutes);
app.use('/odata', odataRoutes);
app.use('/excel', excelRoutes);
// Start the application
if (process.env.NODE_ENV !== 'test') {

View File

@ -38,6 +38,7 @@ import noteRoutes from './misc/notes.js';
import userNotifierRoutes from './misc/usernotifiers.js';
import notificationRoutes from './misc/notifications.js';
import odataRoutes from './misc/odata.js';
import excelRoutes from './misc/excel.js';
export {
userRoutes,
@ -80,4 +81,5 @@ export {
userNotifierRoutes,
notificationRoutes,
odataRoutes,
excelRoutes,
};

16
src/routes/misc/excel.js Normal file
View File

@ -0,0 +1,16 @@
import express from 'express';
import { isAuthenticated } from '../../keycloak.js';
import {
excelExportRouteHandler,
excelOpenRouteHandler,
excelTempRouteHandler,
} from '../../services/misc/excel.js';
const router = express.Router();
// Temp route must come before /:objectType so "temp" is not matched as objectType
router.get('/temp/:token', excelTempRouteHandler);
router.post('/:objectType/open', isAuthenticated, excelOpenRouteHandler);
router.get('/:objectType', isAuthenticated, excelExportRouteHandler);
export default router;

289
src/services/misc/excel.js Normal file
View File

@ -0,0 +1,289 @@
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);
}
}
};