KLEINUILTIES-WEBAPP/server.js
Slim Uiltjies App 1939bf2ef4 Initial commit: Slim Uiltjies AfterCare invoice system
- Progressive Web App with Express + JSON backend
- PIN-based auth, multi-device sync
- Invoice + Statement PDF generation (branded, Grandstander + Nunito)
- Mobile + Desktop responsive layouts
- n8n backup integration
- Custom logo upload
- Settings for business info, fees, banking
- Bulk invoice generation
- Web Share API integration
2026-08-08 09:25:17 +02:00

236 lines
7.9 KiB
JavaScript

/* Slim Uiltjies AfterCare — Express + JSON file backend
------------------------------------------------------------
• Serves the PWA as static files
• Stores all data in a single JSON file (mounted to /data on Coolify)
• PIN-based auth, simple session tokens held in memory
• Atomic file writes (write to .tmp, rename)
• Write queue to prevent races between concurrent pushes
ENV vars:
PORT (default 3000)
ADMIN_PIN (default 847291) — change before deploying
DATA_FILE (default ./data.json — mount a volume here in production)
*/
const express = require('express');
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const crypto = require('crypto');
const PORT = Number(process.env.PORT) || 3000;
const ADMIN_PIN = process.env.ADMIN_PIN || '847291';
const DATA_FILE = process.env.DATA_FILE || path.join(__dirname, 'data', 'data.json');
const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL || '';
const N8N_WEBHOOK_TOKEN = process.env.N8N_WEBHOOK_TOKEN || '';
const app = express();
app.disable('x-powered-by');
app.use(express.json({ limit: '2mb' }));
// CORS for local dev (when running frontend separately). In prod both
// are served from the same origin so CORS is a no-op.
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Auth-Token');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
// ============================================================
// Data layer
// ============================================================
function defaultState() {
return {
version: 1,
business: null,
parents: [],
children: [],
invoices: [],
payments: [],
meta: { seeded: false, nextReceipt: 1, lastUpdated: new Date().toISOString() },
_updatedAt: new Date().toISOString(),
};
}
async function ensureDataDir() {
const dir = path.dirname(DATA_FILE);
await fsp.mkdir(dir, { recursive: true });
}
async function readState() {
try {
const raw = await fsp.readFile(DATA_FILE, 'utf8');
const data = JSON.parse(raw);
// Sanity check + fill missing fields
return Object.assign(defaultState(), data, {
_updatedAt: data._updatedAt || new Date().toISOString(),
});
} catch (e) {
if (e.code === 'ENOENT') return defaultState();
console.error('[data] read failed:', e.message);
throw e;
}
}
// Serialize writes so concurrent pushes don't clobber
let writeQueue = Promise.resolve();
let writeInProgress = false;
function writeState(state) {
const job = writeQueue.then(async () => {
writeInProgress = true;
try {
state._updatedAt = new Date().toISOString();
if (state.meta) state.meta.lastUpdated = state._updatedAt;
const tmp = DATA_FILE + '.tmp';
await fsp.writeFile(tmp, JSON.stringify(state, null, 2));
await fsp.rename(tmp, DATA_FILE); // atomic on the same filesystem
// Fire-and-forget n8n backup
if (N8N_WEBHOOK_URL) notifyN8n(state).catch(() => {});
} finally {
writeInProgress = false;
}
});
writeQueue = job.catch((e) => {
console.error('[data] write failed:', e.message);
});
return job;
}
async function notifyN8n(state) {
try {
const headers = { 'Content-Type': 'application/json' };
if (N8N_WEBHOOK_TOKEN) headers['Authorization'] = `Bearer ${N8N_WEBHOOK_TOKEN}`;
const body = { ...state, _backupAt: new Date().toISOString() };
const res = await fetch(N8N_WEBHOOK_URL, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (!res.ok) console.warn(`[n8n] webhook returned ${res.status}`);
} catch (e) {
console.warn('[n8n] webhook failed:', e.message);
}
}
// ============================================================
// Auth — PIN + session tokens
// ============================================================
const sessions = new Map();
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
function newToken() {
return crypto.randomBytes(24).toString('base64url');
}
function isAuthenticated(req) {
const token = req.headers['x-auth-token'];
if (!token) return false;
const createdAt = sessions.get(token);
if (!createdAt) return false;
if (Date.now() - createdAt > SESSION_TTL_MS) {
sessions.delete(token);
return false;
}
return true;
}
// Garbage-collect expired sessions every hour
setInterval(() => {
const now = Date.now();
for (const [t, ts] of sessions) if (now - ts > SESSION_TTL_MS) sessions.delete(t);
}, 1000 * 60 * 60).unref();
// ============================================================
// API routes
// ============================================================
// Public
app.get('/api/health', (_req, res) => res.json({ ok: true, ts: Date.now() }));
app.post('/api/auth', (req, res) => {
const { pin } = req.body || {};
if (typeof pin !== 'string' || !pin) return res.status(400).json({ error: 'PIN required' });
if (pin !== ADMIN_PIN) return res.status(401).json({ error: 'Invalid PIN' });
const token = newToken();
sessions.set(token, Date.now());
res.json({ token, ttlSeconds: SESSION_TTL_MS / 1000 });
});
// Protected
app.get('/api/state', async (req, res, next) => {
if (!isAuthenticated(req)) return res.status(401).json({ error: 'Unauthorized' });
try {
const state = await readState();
res.json(state);
} catch (e) { next(e); }
});
app.post('/api/state', async (req, res, next) => {
if (!isAuthenticated(req)) return res.status(401).json({ error: 'Unauthorized' });
const state = req.body;
if (!state || typeof state !== 'object') return res.status(400).json({ error: 'Invalid state object' });
// Lightweight validation
const known = ['business', 'parents', 'children', 'invoices', 'payments', 'meta', 'version'];
const cleaned = {};
for (const k of known) if (k in state) cleaned[k] = state[k];
for (const k of ['parents', 'children', 'invoices', 'payments']) {
if (cleaned[k] !== undefined && !Array.isArray(cleaned[k])) {
return res.status(400).json({ error: `${k} must be an array` });
}
}
cleaned.version = cleaned.version || 1;
try {
await writeState(cleaned);
res.json({ ok: true, updatedAt: cleaned._updatedAt });
} catch (e) { next(e); }
});
app.post('/api/logout', (req, res) => {
const token = req.headers['x-auth-token'];
if (token) sessions.delete(token);
res.json({ ok: true });
});
// ============================================================
// Static frontend (serves index.html, css, js, vendor, icons, etc.)
// ============================================================
app.use(express.static(__dirname, {
extensions: ['html'],
setHeaders: (res, filePath) => {
// Don't cache HTML so updates ship immediately
if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache');
},
}));
// SPA fallback for client-side routing (everything else → index.html)
app.get('*', (_req, res) => res.sendFile(path.join(__dirname, 'index.html')));
// ============================================================
// Error handler
// ============================================================
app.use((err, _req, res, _next) => {
console.error('[server] error:', err);
res.status(500).json({ error: err.message || 'Internal error' });
});
// ============================================================
// Boot
// ============================================================
(async () => {
try {
await ensureDataDir();
await readState(); // touch the file
console.log(`[server] data file: ${DATA_FILE}`);
} catch (e) {
console.error('[server] failed to initialise data:', e.message);
process.exit(1);
}
app.listen(PORT, '0.0.0.0', () => {
console.log(`[server] Slim Uiltjies listening on :${PORT}`);
console.log(`[server] PIN: ${ADMIN_PIN === '847291' ? '(default 847291 — change ADMIN_PIN!)' : '(custom)'}`);
});
})();