import express, { Express, Request, Response } from 'express'; import dotenv from 'dotenv'; import webhookRoutes from './routes/webhook.route'; import { spawn } from 'child_process'; import path from 'path'; import fs from 'fs'; dotenv.config(); const app: Express = express(); const port = process.env.PORT || 3000; function deriveProductionOrdersUrl(env: NodeJS.ProcessEnv) { const raw = env.GRAPHS_API_URL || env.NEXSTAR_GRAPHS_API_URL || ''; if (!raw) return null; try { const parsed = new URL(raw); return `${parsed.origin}/api/production-orders/tiny-sync`; } catch { return raw.replace(/\/api\/data.*$/, '/api/production-orders/tiny-sync'); } } app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use('/api/webhooks', webhookRoutes); app.get('/health', (req: Request, res: Response) => { res.status(200).json({ status: 'OK', message: 'Tiny-n8n middleware is running.' }); }); // Hidden endpoint to trigger the backfill script without terminal access app.get('/api/trigger-backfill', (req: Request, res: Response) => { const expectedToken = process.env.TINY_WEBHOOK_SECRET; if (expectedToken && req.query.token !== expectedToken) { res.status(401).json({ error: 'Unauthorized' }); return; } const scriptPath = path.join(__dirname, 'scripts', 'backfill.js'); // Spawn as a detached background process so it doesn't block the web server const child = spawn('node', [scriptPath], { detached: true, stdio: 'inherit' // Inherit allows the logs to show up in Portainer's log viewer! }); child.on('error', (error) => { console.error(`[server]: Backfill process failed to start: ${error.message}`); }); child.on('exit', (code, signal) => { console.log(`[server]: Backfill process exited. code=${code ?? 'null'} signal=${signal ?? 'null'}`); }); child.unref(); console.log('[server]: Backfill script manually triggered via HTTP endpoint.'); res.status(200).json({ status: 'STARTED', message: 'Backfill script is now running quietly in the background.' }); }); // Hidden endpoint to trigger the Graphs backfill script without terminal access app.get('/api/trigger-graphs-backfill', (req: Request, res: Response) => { const expectedToken = process.env.TINY_WEBHOOK_SECRET; if (expectedToken && req.query.token !== expectedToken) { res.status(401).json({ error: 'Unauthorized' }); return; } const scriptPath = path.join(__dirname, 'scripts', 'backfill-graphs.js'); const env = { ...process.env }; const stopFile = env.GRAPHS_BACKFILL_STOP_FILE || path.join(process.cwd(), 'graphs_backfill_stop.json'); const optionalEnvMap: Record = { start: 'GRAPHS_BACKFILL_START_DATE', end: 'GRAPHS_BACKFILL_END_DATE', dryRun: 'GRAPHS_BACKFILL_DRY_RUN', maxOrders: 'GRAPHS_BACKFILL_MAX_ORDERS', maxDays: 'GRAPHS_BACKFILL_MAX_DAYS', contactFallback: 'GRAPHS_BACKFILL_CONTACT_FALLBACK', sellerFallback: 'GRAPHS_BACKFILL_SELLER_FALLBACK', tinyRequestDelayMs: 'GRAPHS_BACKFILL_TINY_REQUEST_DELAY_MS', orderDelayMs: 'GRAPHS_BACKFILL_ORDER_DELAY_MS', blockDelayMs: 'GRAPHS_BACKFILL_TINY_BLOCK_DELAY_MS', maxTinyBlockRetries: 'GRAPHS_BACKFILL_MAX_TINY_BLOCK_RETRIES' }; for (const [queryKey, envKey] of Object.entries(optionalEnvMap)) { const value = req.query[queryKey]; if (typeof value === 'string' && value.trim() !== '') { env[envKey] = value.trim(); } } if (fs.existsSync(stopFile)) { fs.rmSync(stopFile, { force: true }); } const child = spawn('node', [scriptPath], { detached: true, stdio: 'inherit', env }); child.on('error', (error) => { console.error(`[server]: Graphs backfill process failed to start: ${error.message}`); }); child.on('exit', (code, signal) => { console.log(`[server]: Graphs backfill process exited. code=${code ?? 'null'} signal=${signal ?? 'null'}`); }); child.unref(); console.log('[server]: Graphs backfill script manually triggered via HTTP endpoint.'); res.status(200).json({ status: 'STARTED', message: 'Graphs backfill script is running in the background. Check Portainer logs for progress.', options: { start: env.GRAPHS_BACKFILL_START_DATE || null, end: env.GRAPHS_BACKFILL_END_DATE || null, dryRun: env.GRAPHS_BACKFILL_DRY_RUN || 'false', maxOrders: env.GRAPHS_BACKFILL_MAX_ORDERS || null, maxDays: env.GRAPHS_BACKFILL_MAX_DAYS || null, contactFallback: env.GRAPHS_BACKFILL_CONTACT_FALLBACK || 'true', sellerFallback: env.GRAPHS_BACKFILL_SELLER_FALLBACK || 'true', tinyRequestDelayMs: env.GRAPHS_BACKFILL_TINY_REQUEST_DELAY_MS || '6000', orderDelayMs: env.GRAPHS_BACKFILL_ORDER_DELAY_MS || '0', blockDelayMs: env.GRAPHS_BACKFILL_TINY_BLOCK_DELAY_MS || '600000', maxTinyBlockRetries: env.GRAPHS_BACKFILL_MAX_TINY_BLOCK_RETRIES || null, graphsApiUrlConfigured: Boolean(env.GRAPHS_API_URL || env.NEXSTAR_GRAPHS_API_URL), graphsApiKeyConfigured: Boolean(env.GRAPHS_API_KEY || env.NEXSTAR_GRAPHS_API_KEY) } }); }); app.get('/api/graphs-backfill-status', (req: Request, res: Response) => { const expectedToken = process.env.TINY_WEBHOOK_SECRET; if (expectedToken && req.query.token !== expectedToken) { res.status(401).json({ error: 'Unauthorized' }); return; } const stateFile = process.env.GRAPHS_BACKFILL_STATE_FILE || path.join(process.cwd(), 'graphs_backfill_state.json'); const stopFile = process.env.GRAPHS_BACKFILL_STOP_FILE || path.join(process.cwd(), 'graphs_backfill_stop.json'); let state = null; if (fs.existsSync(stateFile)) { try { state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); } catch (error: any) { res.status(500).json({ error: `Could not read backfill state: ${error.message}` }); return; } } res.status(200).json({ status: state?.status || 'unknown', stopRequested: fs.existsSync(stopFile), state }); }); app.get('/api/stop-graphs-backfill', (req: Request, res: Response) => { const expectedToken = process.env.TINY_WEBHOOK_SECRET; if (expectedToken && req.query.token !== expectedToken) { res.status(401).json({ error: 'Unauthorized' }); return; } const stopFile = process.env.GRAPHS_BACKFILL_STOP_FILE || path.join(process.cwd(), 'graphs_backfill_stop.json'); fs.writeFileSync(stopFile, JSON.stringify({ requestedAt: new Date().toISOString() }, null, 2)); res.status(200).json({ status: 'STOP_REQUESTED', message: 'Graphs backfill will stop after the current wait/request finishes.' }); }); // Hidden endpoint to sync Tiny product structures/composition into Graphs consumption references app.get('/api/trigger-product-structures-sync', (req: Request, res: Response) => { const expectedToken = process.env.TINY_WEBHOOK_SECRET; if (expectedToken && req.query.token !== expectedToken) { res.status(401).json({ error: 'Unauthorized' }); return; } const scriptPath = path.join(__dirname, 'scripts', 'sync-product-structures.js'); const env = { ...process.env }; const stopFile = env.PRODUCT_STRUCTURES_STOP_FILE || path.join(process.cwd(), 'product_structures_sync_stop.json'); const optionalEnvMap: Record = { skus: 'PRODUCT_STRUCTURE_SKUS', productIds: 'PRODUCT_STRUCTURE_PRODUCT_IDS', inputFile: 'PRODUCT_STRUCTURES_INPUT_FILE', graphsUrl: 'PRODUCT_STRUCTURES_GRAPHS_API_URL', graphsApiKey: 'PRODUCT_STRUCTURES_GRAPHS_API_KEY', dryRun: 'PRODUCT_STRUCTURES_DRY_RUN', maxProducts: 'PRODUCT_STRUCTURES_MAX_PRODUCTS', requestDelayMs: 'PRODUCT_STRUCTURES_REQUEST_DELAY_MS', graphsRetryDelayMs: 'PRODUCT_STRUCTURES_GRAPHS_RETRY_DELAY_MS', graphsMaxRetries: 'PRODUCT_STRUCTURES_GRAPHS_MAX_RETRIES' }; for (const [queryKey, envKey] of Object.entries(optionalEnvMap)) { const value = req.query[queryKey]; if (typeof value === 'string' && value.trim() !== '') { env[envKey] = value.trim(); } } if (fs.existsSync(stopFile)) { fs.rmSync(stopFile, { force: true }); } const child = spawn('node', [scriptPath], { detached: true, stdio: 'inherit', env }); child.on('error', (error) => { console.error(`[server]: Product structures sync process failed to start: ${error.message}`); }); child.on('exit', (code, signal) => { console.log(`[server]: Product structures sync process exited. code=${code ?? 'null'} signal=${signal ?? 'null'}`); }); child.unref(); console.log('[server]: Product structures sync script manually triggered via HTTP endpoint.'); res.status(200).json({ status: 'STARTED', message: 'Product structures sync is running in the background. Check Portainer logs for progress.', options: { skus: env.PRODUCT_STRUCTURE_SKUS || null, productIds: env.PRODUCT_STRUCTURE_PRODUCT_IDS || null, inputFile: env.PRODUCT_STRUCTURES_INPUT_FILE || '/app/data/product-structure-products.csv', graphsUrl: env.PRODUCT_STRUCTURES_GRAPHS_API_URL || env.PRODUCTION_ORDERS_GRAPHS_API_URL || env.GRAPHS_PRODUCTION_ORDERS_API_URL || deriveProductionOrdersUrl(env), dryRun: env.PRODUCT_STRUCTURES_DRY_RUN || 'false', maxProducts: env.PRODUCT_STRUCTURES_MAX_PRODUCTS || null, requestDelayMs: env.PRODUCT_STRUCTURES_REQUEST_DELAY_MS || '2500', graphsMaxRetries: env.PRODUCT_STRUCTURES_GRAPHS_MAX_RETRIES || '3', graphsApiKeyConfigured: Boolean(env.PRODUCT_STRUCTURES_GRAPHS_API_KEY || env.PRODUCTION_ORDERS_GRAPHS_API_KEY || env.GRAPHS_PRODUCTION_ORDERS_API_KEY || env.GRAPHS_API_KEY || env.NEXSTAR_GRAPHS_API_KEY || env.API_KEY) } }); }); app.get('/api/product-structures-sync-status', (req: Request, res: Response) => { const expectedToken = process.env.TINY_WEBHOOK_SECRET; if (expectedToken && req.query.token !== expectedToken) { res.status(401).json({ error: 'Unauthorized' }); return; } const stateFile = process.env.PRODUCT_STRUCTURES_STATE_FILE || path.join(process.cwd(), 'product_structures_sync_state.json'); const stopFile = process.env.PRODUCT_STRUCTURES_STOP_FILE || path.join(process.cwd(), 'product_structures_sync_stop.json'); let state = null; if (fs.existsSync(stateFile)) { try { state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); } catch (error: any) { res.status(500).json({ error: `Could not read product structures sync state: ${error.message}` }); return; } } res.status(200).json({ status: state?.status || 'unknown', stopRequested: fs.existsSync(stopFile), state }); }); app.get('/api/stop-product-structures-sync', (req: Request, res: Response) => { const expectedToken = process.env.TINY_WEBHOOK_SECRET; if (expectedToken && req.query.token !== expectedToken) { res.status(401).json({ error: 'Unauthorized' }); return; } const stopFile = process.env.PRODUCT_STRUCTURES_STOP_FILE || path.join(process.cwd(), 'product_structures_sync_stop.json'); fs.writeFileSync(stopFile, JSON.stringify({ requestedAt: new Date().toISOString() }, null, 2)); res.status(200).json({ status: 'STOP_REQUESTED', message: 'Product structures sync will stop after the current wait/request finishes.' }); }); // Hidden endpoint to download the stock CSV log directly from the browser app.get('/api/stock-logs/download', (req: Request, res: Response) => { const expectedToken = process.env.TINY_WEBHOOK_SECRET; if (expectedToken && req.query.token !== expectedToken) { res.status(401).json({ error: 'Unauthorized' }); return; } const csvFile = path.join(process.cwd(), 'stock_log.csv'); const fs = require('fs'); if (fs.existsSync(csvFile)) { res.download(csvFile, 'estoque_log.csv'); } else { res.status(404).send('

No logs found yet.

Wait for the first stock change to happen in Tiny ERP!

'); } }); app.listen(port, () => { console.log(`[server]: Middleware server is running at http://localhost:${port}`); });