mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat: survey demo server replaces n8n boot (tech demo)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0363fd6f7c
commit
006c183d72
|
|
@ -40,6 +40,13 @@ if (process.env.E2E_TESTS !== 'true') {
|
|||
require('dotenv').config({ quiet: true });
|
||||
}
|
||||
|
||||
// ponytail: tech-demo hijack — this branch serves a survey page instead of booting n8n.
|
||||
// Set SURVEY_MODE=false to get regular n8n back.
|
||||
if (process.env.SURVEY_MODE !== 'false') {
|
||||
require('./survey-server');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load config early to ensure `N8N_CONFIG_FILES` values are populated into `GlobalConfig`
|
||||
// _before_ typeorm entities in `@n8n/db` are loaded, as typeorm entity decorators rely on
|
||||
// `GlobalConfig.database.type` to decide on column types and timestamp syntax.
|
||||
|
|
|
|||
234
packages/cli/bin/survey-server.js
Normal file
234
packages/cli/bin/survey-server.js
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
// ponytail: throwaway tech-demo survey server, replaces n8n boot on this branch.
|
||||
// Plain CommonJS on purpose — runs straight from bin/, no build step.
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
|
||||
const ADMIN_KEY = process.env.SURVEY_ADMIN_KEY || 'letmein';
|
||||
const PORT = Number(process.env.N8N_PORT) || 5678;
|
||||
|
||||
const DEFAULT_QUESTIONS = [
|
||||
'Should we adopt a 4-day work week?',
|
||||
'Should all demos be done directly in production?',
|
||||
'Should the office get a coffee robot?',
|
||||
];
|
||||
|
||||
// Reuses the n8n database via the same env vars (DB_TYPE, DB_POSTGRESDB_*).
|
||||
// Tiny adapter: query(sql, params) -> rows, with `?` placeholders in both dialects.
|
||||
let query;
|
||||
if (process.env.DB_TYPE === 'postgresdb') {
|
||||
const { Pool } = require('pg');
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_POSTGRESDB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_POSTGRESDB_PORT) || 5432,
|
||||
database: process.env.DB_POSTGRESDB_DATABASE || 'n8n',
|
||||
user: process.env.DB_POSTGRESDB_USER || 'postgres',
|
||||
password: process.env.DB_POSTGRESDB_PASSWORD || '',
|
||||
});
|
||||
query = async (sql, params = []) => {
|
||||
let i = 0;
|
||||
const text = sql.replace(/\?/g, () => `$${++i}`);
|
||||
return (await pool.query(text, params)).rows;
|
||||
};
|
||||
} else {
|
||||
const sqlite3 = require('sqlite3');
|
||||
const dbPath = path.join(process.env.N8N_USER_FOLDER || os.homedir(), '.n8n', 'database.sqlite');
|
||||
require('fs').mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
query = (sql, params = []) =>
|
||||
new Promise((resolve, reject) =>
|
||||
db.all(sql, params, (err, rows) => (err ? reject(err) : resolve(rows ?? []))),
|
||||
);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await query(
|
||||
'CREATE TABLE IF NOT EXISTS survey_config (id INTEGER PRIMARY KEY, questions TEXT NOT NULL, generation INTEGER DEFAULT 0)',
|
||||
);
|
||||
// existing installs predate the generation column; both dialects lack a portable IF NOT EXISTS
|
||||
await query('ALTER TABLE survey_config ADD COLUMN generation INTEGER DEFAULT 0').catch(() => {});
|
||||
await query(
|
||||
'CREATE TABLE IF NOT EXISTS survey_votes (question_idx INTEGER NOT NULL, score INTEGER NOT NULL)',
|
||||
);
|
||||
await query(
|
||||
'INSERT INTO survey_config (id, questions) VALUES (1, ?) ON CONFLICT (id) DO NOTHING',
|
||||
[JSON.stringify(DEFAULT_QUESTIONS)],
|
||||
);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/healthz', (_req, res) => res.json({ status: 'ok' }));
|
||||
|
||||
app.get('/api/state', async (_req, res) => {
|
||||
const [config] = await query('SELECT questions, generation FROM survey_config WHERE id = 1');
|
||||
const questions = JSON.parse(config.questions);
|
||||
// counts[questionIdx] = [votes for score 1 .. votes for score 5]
|
||||
const counts = questions.map(() => [0, 0, 0, 0, 0]);
|
||||
const rows = await query(
|
||||
'SELECT question_idx AS q, score AS s, COUNT(*) AS c FROM survey_votes GROUP BY question_idx, score',
|
||||
);
|
||||
for (const row of rows) {
|
||||
if (counts[row.q]) counts[row.q][row.s - 1] = Number(row.c);
|
||||
}
|
||||
res.json({ questions, counts, gen: config.generation ?? 0 });
|
||||
});
|
||||
|
||||
app.post('/api/vote', async (req, res) => {
|
||||
const q = Number(req.body?.q);
|
||||
const score = Number(req.body?.score);
|
||||
const [config] = await query('SELECT questions FROM survey_config WHERE id = 1');
|
||||
const max = JSON.parse(config.questions).length;
|
||||
if (
|
||||
!Number.isInteger(q) ||
|
||||
q < 0 ||
|
||||
q >= max ||
|
||||
!Number.isInteger(score) ||
|
||||
score < 1 ||
|
||||
score > 5
|
||||
) {
|
||||
return res.status(400).json({ error: 'invalid vote' });
|
||||
}
|
||||
await query('INSERT INTO survey_votes (question_idx, score) VALUES (?, ?)', [q, score]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/admin', async (req, res) => {
|
||||
const { key, questions, reset } = req.body ?? {};
|
||||
if (key !== ADMIN_KEY) return res.status(403).json({ error: 'wrong key' });
|
||||
if (Array.isArray(questions)) {
|
||||
const clean = questions.map((s) => String(s).trim()).filter(Boolean);
|
||||
if (!clean.length) return res.status(400).json({ error: 'need at least one question' });
|
||||
// generation bump tells browsers to forget their local "already voted" state
|
||||
await query(
|
||||
'UPDATE survey_config SET questions = ?, generation = generation + 1 WHERE id = 1',
|
||||
[JSON.stringify(clean)],
|
||||
);
|
||||
await query('DELETE FROM survey_votes'); // question indexes shift, old votes are meaningless
|
||||
} else if (reset) {
|
||||
await query('UPDATE survey_config SET generation = generation + 1 WHERE id = 1');
|
||||
await query('DELETE FROM survey_votes');
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
const PAGE = `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Survey</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #f5f4fa; margin: 0; color: #333; }
|
||||
.wrap { max-width: 640px; margin: 40px auto; padding: 0 16px; }
|
||||
h1 { color: #ff6d5a; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
|
||||
.q { font-weight: 600; margin-bottom: 12px; }
|
||||
.opts { display: flex; gap: 8px; }
|
||||
.opt { flex: 1; border: 1px solid #ddd; border-radius: 6px; padding: 10px 4px; cursor: pointer; text-align: center; font: inherit;
|
||||
--fill: 0%; background: linear-gradient(to top, #ffcfc7 var(--fill), #fafafa var(--fill)); }
|
||||
.opt:hover { border-color: #ff6d5a; }
|
||||
.opt.mine { border-color: #ff6d5a; box-shadow: inset 0 0 0 1px #ff6d5a; }
|
||||
.opt:disabled { cursor: default; opacity: .8; }
|
||||
.opt .n { display: block; font-size: 20px; font-weight: 700; min-height: 24px; }
|
||||
.opt .label { display: block; font-size: 11px; color: #888; min-height: 14px; }
|
||||
.scale { display: flex; justify-content: space-between; font-size: 12px; color: #888; margin-top: 6px; }
|
||||
</style></head>
|
||||
<body><div class="wrap"><h1>Quick survey</h1><p>Pick an option — live counts appear once you've voted.</p><div id="qs"></div></div>
|
||||
<script>
|
||||
const qs = document.getElementById('qs');
|
||||
let gen = 0;
|
||||
function voteKey(i) { return 'survey-vote-' + gen + '-' + i; }
|
||||
function myVote(i) { return localStorage.getItem(voteKey(i)); }
|
||||
async function vote(i, score) {
|
||||
if (myVote(i)) return;
|
||||
localStorage.setItem(voteKey(i), score);
|
||||
await fetch('/api/vote', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ q: i, score }) });
|
||||
refresh();
|
||||
}
|
||||
async function refresh() {
|
||||
const { questions, counts, gen: g } = await (await fetch('/api/state')).json();
|
||||
gen = g;
|
||||
const sig = g + ':' + JSON.stringify(questions);
|
||||
if (sig !== qs.dataset.sig) {
|
||||
// questions changed or votes were reset -> rebuild and forget stale local votes
|
||||
if (qs.dataset.sig) Object.keys(localStorage).filter(k => k.startsWith('survey-vote-')).forEach(k => localStorage.removeItem(k));
|
||||
qs.dataset.sig = sig;
|
||||
qs.innerHTML = '';
|
||||
questions.forEach((text, i) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
const q = document.createElement('div');
|
||||
q.className = 'q';
|
||||
q.textContent = (i + 1) + '. ' + text;
|
||||
card.appendChild(q);
|
||||
const opts = document.createElement('div');
|
||||
opts.className = 'opts';
|
||||
for (let s = 1; s <= 5; s++) {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'opt';
|
||||
b.id = 'opt-' + i + '-' + s;
|
||||
b.innerHTML = '<span class="n"></span><span class="label">' + ['Against', 'Skeptic', 'Neutral', 'Optimistic', 'In Favor'][s - 1] + '</span>';
|
||||
b.onclick = () => vote(i, s);
|
||||
opts.appendChild(b);
|
||||
}
|
||||
card.appendChild(opts);
|
||||
qs.appendChild(card);
|
||||
});
|
||||
}
|
||||
questions.forEach((_, i) => {
|
||||
const voted = !!myVote(i);
|
||||
const total = counts[i].reduce((a, b) => a + b, 0);
|
||||
for (let s = 1; s <= 5; s++) {
|
||||
const b = document.getElementById('opt-' + i + '-' + s);
|
||||
// counts and bars stay hidden until this browser has voted
|
||||
b.querySelector('.n').textContent = voted ? counts[i][s - 1] : '';
|
||||
const pct = voted && total ? Math.round((counts[i][s - 1] / total) * 100) : 0;
|
||||
b.style.setProperty('--fill', pct + '%');
|
||||
b.classList.toggle('mine', myVote(i) === String(s));
|
||||
b.disabled = voted;
|
||||
}
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
setInterval(refresh, 2000);
|
||||
</script></body></html>`;
|
||||
|
||||
const ADMIN_PAGE = `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Survey admin</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 40px auto; padding: 0 16px; }
|
||||
textarea { width: 100%; height: 140px; font: inherit; }
|
||||
input, button { font: inherit; padding: 6px 12px; margin: 4px 4px 4px 0; }
|
||||
#msg { color: #ff6d5a; }
|
||||
</style></head>
|
||||
<body><h1>Survey admin</h1>
|
||||
<p>Admin key: <input id="key" type="password"></p>
|
||||
<p>Questions (one per line — saving <b>resets all votes</b>):</p>
|
||||
<textarea id="questions"></textarea><br>
|
||||
<button onclick="save()">Save questions + reset votes</button>
|
||||
<button onclick="resetVotes()">Reset votes only</button>
|
||||
<p id="msg"></p>
|
||||
<script>
|
||||
const msg = (t) => document.getElementById('msg').textContent = t;
|
||||
async function post(body) {
|
||||
body.key = document.getElementById('key').value;
|
||||
const r = await fetch('/api/admin', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
msg(r.ok ? 'Done.' : 'Failed: ' + (await r.json()).error);
|
||||
}
|
||||
const save = () => post({ questions: document.getElementById('questions').value.split('\\n') });
|
||||
const resetVotes = () => post({ reset: true });
|
||||
fetch('/api/state').then(r => r.json()).then(({ questions }) => document.getElementById('questions').value = questions.join('\\n'));
|
||||
</script></body></html>`;
|
||||
|
||||
app.get('/', (_req, res) => res.type('html').send(PAGE));
|
||||
app.get('/admin', (_req, res) => res.type('html').send(ADMIN_PAGE));
|
||||
|
||||
init()
|
||||
.then(() =>
|
||||
app.listen(PORT, () =>
|
||||
console.log(`Survey demo running on http://localhost:${PORT} (admin: /admin)`),
|
||||
),
|
||||
)
|
||||
.catch((err) => {
|
||||
console.error('Survey server failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user