319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
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<string, string> = {
|
|
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<string, string> = {
|
|
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('<html><body><h2>No logs found yet.</h2><p>Wait for the first stock change to happen in Tiny ERP!</p></body></html>');
|
|
}
|
|
});
|
|
|
|
app.listen(port, () => {
|
|
console.log(`[server]: Middleware server is running at http://localhost:${port}`);
|
|
});
|