Files
tiny-webhook/src/index.ts
Cauê Faleiros 12aa5c7f83
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m23s
Make product structure sync use product export
2026-07-23 14:03:43 -03:00

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