- 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
236 lines
7.9 KiB
JavaScript
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)'}`);
|
|
});
|
|
})();
|