This commit is contained in:
odweta
2026-09-10 15:53:08 +02:00
parent 42cc4eafa8
commit 82a6bc20f9
13 changed files with 3090 additions and 115 deletions
+616
View File
@@ -1,7 +1,618 @@
const express = require('express');
const path = require('path');
const crypto = require('crypto');
const archiver = require('archiver');
const PDFDocument = require('pdfkit');
const QRCode = require('qrcode');
const { pool, initializeDatabase } = require('./db');
const { normalizeInvoiceData } = require('./invoice-data');
const app = express();
const sessionDurationMs = 1000 * 60 * 60 * 24 * 30;
app.use(express.json());
const hashPassword = (password, salt = crypto.randomBytes(16).toString('hex')) => new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (error, derivedKey) => {
if (error) {
reject(error);
return;
}
resolve(`${salt}:${derivedKey.toString('hex')}`);
});
});
const verifyPassword = async (password, storedHash) => {
const [salt, expectedHex] = storedHash.split(':');
const actualHash = await hashPassword(password, salt);
const actualBuffer = Buffer.from(actualHash.split(':')[1], 'hex');
const expectedBuffer = Buffer.from(expectedHex, 'hex');
return actualBuffer.length === expectedBuffer.length
&& crypto.timingSafeEqual(actualBuffer, expectedBuffer);
};
const getSessionToken = (req) => {
const cookie = req.headers.cookie || '';
const sessionCookie = cookie.split(';').map((part) => part.trim())
.find((part) => part.startsWith('fakturovac_session='));
return sessionCookie ? decodeURIComponent(sessionCookie.split('=').slice(1).join('=')) : '';
};
const setSessionCookie = (res, token) => {
const secure = process.env.SESSION_COOKIE_SECURE === 'true' ? '; Secure' : '';
res.setHeader('Set-Cookie', `fakturovac_session=${encodeURIComponent(token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${sessionDurationMs / 1000}${secure}`);
};
const clearSessionCookie = (res) => {
const secure = process.env.SESSION_COOKIE_SECURE === 'true' ? '; Secure' : '';
res.setHeader('Set-Cookie', `fakturovac_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${secure}`);
};
const createSession = async (userId) => {
const token = crypto.randomBytes(32).toString('hex');
await pool.query(
'INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, NOW() + INTERVAL \'30 days\')',
[token, userId]
);
return token;
};
const requireAuth = async (req, res, next) => {
try {
await initializeDatabase();
const token = getSessionToken(req);
if (!token) {
res.status(401).json({ error: 'Nejste přihlášeni.' });
return;
}
const result = await pool.query(
'SELECT user_id FROM sessions WHERE id = $1 AND expires_at > NOW()',
[token]
);
if (!result.rowCount) {
clearSessionCookie(res);
res.status(401).json({ error: 'Relace vypršela.' });
return;
}
req.userId = result.rows[0].user_id;
next();
} catch (error) {
next(error);
}
};
const validateCredentials = (email, password) => typeof email === 'string'
&& email.includes('@')
&& typeof password === 'string'
&& password.length >= 8;
const profileFields = ['nazevSpolecnosti', 'ico', 'jmeno', 'prijmeni', 'uliceCp', 'psc', 'mesto'];
const profileValues = (body) => profileFields.map((field) => typeof body[field] === 'string' ? body[field].trim() : '');
const profileResponse = (row) => ({
typSubjektu: row.typ_subjektu === 'spolecnost' ? 'spolecnost' : 'osoba',
nazevSpolecnosti: row.nazev_spolecnosti,
ico: row.ico,
jmeno: row.jmeno,
prijmeni: row.prijmeni,
uliceCp: row.ulice_cp,
psc: row.psc,
mesto: row.mesto
});
const clientResponse = (row) => ({
id: row.id,
typSubjektu: row.typ_subjektu === 'spolecnost' ? 'spolecnost' : 'osoba',
data: profileResponse(row)
});
app.post('/api/auth/register', async (req, res, next) => {
try {
await initializeDatabase();
const email = String(req.body.email || '').trim().toLowerCase();
const password = req.body.password;
if (!validateCredentials(email, password)) {
res.status(400).json({ error: 'Zadejte platný e-mail a heslo dlouhé alespoň 8 znaků.' });
return;
}
const passwordHash = await hashPassword(password);
const result = await pool.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email',
[email, passwordHash]
);
const token = await createSession(result.rows[0].id);
setSessionCookie(res, token);
res.status(201).json({ user: result.rows[0] });
} catch (error) {
if (error.code === '23505') {
res.status(409).json({ error: 'Účet s tímto e-mailem už existuje.' });
return;
}
next(error);
}
});
app.post('/api/auth/login', async (req, res, next) => {
try {
await initializeDatabase();
const email = String(req.body.email || '').trim().toLowerCase();
const result = await pool.query('SELECT id, email, password_hash FROM users WHERE email = $1', [email]);
if (!result.rowCount || !(await verifyPassword(req.body.password || '', result.rows[0].password_hash))) {
res.status(401).json({ error: 'E-mail nebo heslo není správně.' });
return;
}
const token = await createSession(result.rows[0].id);
setSessionCookie(res, token);
res.json({ user: { id: result.rows[0].id, email: result.rows[0].email } });
} catch (error) {
next(error);
}
});
app.post('/api/auth/logout', async (req, res, next) => {
try {
await initializeDatabase();
const token = getSessionToken(req);
if (token) {
await pool.query('DELETE FROM sessions WHERE id = $1', [token]);
}
clearSessionCookie(res);
res.status(204).end();
} catch (error) {
next(error);
}
});
app.get('/api/auth/me', requireAuth, async (req, res, next) => {
try {
const result = await pool.query('SELECT id, email FROM users WHERE id = $1', [req.userId]);
res.json({ user: result.rows[0] });
} catch (error) {
next(error);
}
});
app.post('/api/auth/password', requireAuth, async (req, res, next) => {
try {
const currentPassword = req.body.currentPassword || '';
const newPassword = req.body.newPassword || '';
const result = await pool.query('SELECT password_hash FROM users WHERE id = $1', [req.userId]);
if (!result.rowCount || !(await verifyPassword(currentPassword, result.rows[0].password_hash))) {
res.status(400).json({ error: 'Současné heslo není správně.' });
return;
}
if (!validateCredentials('account@example.com', newPassword)) {
res.status(400).json({ error: 'Nové heslo musí mít alespoň 8 znaků.' });
return;
}
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [await hashPassword(newPassword), req.userId]);
await pool.query('DELETE FROM sessions WHERE user_id = $1 AND id <> $2', [req.userId, getSessionToken(req)]);
res.status(204).end();
} catch (error) {
next(error);
}
});
app.get('/api/supplier', requireAuth, async (req, res, next) => {
try {
const result = await pool.query('SELECT * FROM supplier_profiles WHERE user_id = $1', [req.userId]);
res.json({ supplier: result.rowCount ? profileResponse(result.rows[0]) : null });
} catch (error) {
next(error);
}
});
app.put('/api/supplier', requireAuth, async (req, res, next) => {
try {
const values = profileValues(req.body);
const type = req.body.typSubjektu === 'spolecnost' ? 'spolecnost' : 'osoba';
const result = await pool.query(`
INSERT INTO supplier_profiles (user_id, typ_subjektu, nazev_spolecnosti, ico, jmeno, prijmeni, ulice_cp, psc, mesto)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (user_id) DO UPDATE SET
typ_subjektu = EXCLUDED.typ_subjektu, nazev_spolecnosti = EXCLUDED.nazev_spolecnosti,
ico = EXCLUDED.ico,
jmeno = EXCLUDED.jmeno, prijmeni = EXCLUDED.prijmeni,
ulice_cp = EXCLUDED.ulice_cp, psc = EXCLUDED.psc, mesto = EXCLUDED.mesto
RETURNING *
`, [req.userId, type, ...values]);
res.json({ supplier: profileResponse(result.rows[0]) });
} catch (error) {
next(error);
}
});
app.get('/api/clients', requireAuth, async (req, res, next) => {
try {
const result = await pool.query('SELECT * FROM clients WHERE user_id = $1 ORDER BY created_at, id', [req.userId]);
res.json({ clients: result.rows.map(clientResponse) });
} catch (error) {
next(error);
}
});
app.post('/api/clients', requireAuth, async (req, res, next) => {
try {
const values = profileValues(req.body);
const type = req.body.typSubjektu === 'spolecnost' ? 'spolecnost' : 'osoba';
const result = await pool.query(`
INSERT INTO clients (user_id, typ_subjektu, nazev_spolecnosti, ico, jmeno, prijmeni, ulice_cp, psc, mesto)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *
`, [req.userId, type, ...values]);
res.status(201).json({ client: clientResponse(result.rows[0]) });
} catch (error) {
next(error);
}
});
app.put('/api/clients/:id', requireAuth, async (req, res, next) => {
try {
const values = profileValues(req.body);
const type = req.body.typSubjektu === 'spolecnost' ? 'spolecnost' : 'osoba';
const result = await pool.query(`
UPDATE clients SET typ_subjektu = $1, nazev_spolecnosti = $2, ico = $3, jmeno = $4,
prijmeni = $5, ulice_cp = $6, psc = $7, mesto = $8
WHERE id = $9 AND user_id = $10 RETURNING *
`, [type, ...values, req.params.id, req.userId]);
if (!result.rowCount) {
res.status(404).json({ error: 'Odběratel nebyl nalezen.' });
return;
}
res.json({ client: clientResponse(result.rows[0]) });
} catch (error) {
next(error);
}
});
app.delete('/api/clients/:id', requireAuth, async (req, res, next) => {
try {
await pool.query('DELETE FROM clients WHERE id = $1 AND user_id = $2', [req.params.id, req.userId]);
res.status(204).end();
} catch (error) {
next(error);
}
});
const paymentPresetResponse = (row) => ({
id: row.id,
name: row.name,
accountNumber: row.account_number,
iban: row.iban,
swift: row.swift
});
app.get('/api/payment-presets', requireAuth, async (req, res, next) => {
try {
const result = await pool.query(
'SELECT * FROM payment_presets WHERE user_id = $1 ORDER BY name',
[req.userId]
);
res.json({ presets: result.rows.map(paymentPresetResponse) });
} catch (error) {
next(error);
}
});
app.post('/api/payment-presets', requireAuth, async (req, res, next) => {
try {
const name = String(req.body.name || '').trim();
if (!name) {
res.status(400).json({ error: 'Zadejte název platební konfigurace.' });
return;
}
const result = await pool.query(`
INSERT INTO payment_presets (user_id, name, account_number, iban, swift)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, name) DO UPDATE SET
account_number = EXCLUDED.account_number,
iban = EXCLUDED.iban,
swift = EXCLUDED.swift
RETURNING *
`, [
req.userId,
name,
String(req.body.accountNumber || '').trim(),
String(req.body.iban || '').trim(),
String(req.body.swift || '').trim()
]);
res.status(201).json({ preset: paymentPresetResponse(result.rows[0]) });
} catch (error) {
next(error);
}
});
app.delete('/api/payment-presets/:id', requireAuth, async (req, res, next) => {
try {
await pool.query('DELETE FROM payment_presets WHERE id = $1 AND user_id = $2', [req.params.id, req.userId]);
res.status(204).end();
} catch (error) {
next(error);
}
});
const invoiceResponse = (row) => ({
id: row.id,
invoiceNumber: row.invoice_number,
data: row.invoice_data,
createdAt: row.created_at,
updatedAt: row.updated_at
});
app.get('/api/invoices', requireAuth, async (req, res, next) => {
try {
const result = await pool.query(
'SELECT id, invoice_number, invoice_data, created_at, updated_at FROM invoices WHERE user_id = $1 ORDER BY updated_at DESC, id DESC',
[req.userId]
);
res.json({ invoices: result.rows.map(invoiceResponse) });
} catch (error) {
next(error);
}
});
app.get('/health', async (req, res, next) => {
try {
await initializeDatabase();
await pool.query('SELECT 1');
res.json({ status: 'ok' });
} catch (error) {
next(error);
}
});
app.post('/api/invoices', requireAuth, async (req, res, next) => {
const client = await pool.connect();
try {
const data = normalizeInvoiceData(req.body.data);
if (!data) {
res.status(400).json({ error: 'Neplatná data faktury.' });
return;
}
const invoiceYear = new Date().getFullYear();
await client.query('BEGIN');
await client.query(`
INSERT INTO invoice_counters (user_id, invoice_year, last_number)
SELECT $1, $2, COALESCE(MAX((substring(invoice_number FROM '^F-[0-9]{4}-([0-9]+)$'))::integer), 0)
FROM invoices
WHERE user_id = $1 AND invoice_number LIKE $3
ON CONFLICT (user_id, invoice_year) DO NOTHING
`, [req.userId, invoiceYear, `F-${invoiceYear}-%`]);
const counterResult = await client.query(`
UPDATE invoice_counters
SET last_number = last_number + 1
WHERE user_id = $1 AND invoice_year = $2
RETURNING last_number
`, [req.userId, invoiceYear]);
const invoiceNumber = `F-${invoiceYear}-${String(counterResult.rows[0].last_number).padStart(4, '0')}`;
const result = await client.query(
'INSERT INTO invoices (user_id, invoice_number, invoice_data) VALUES ($1, $2, $3) RETURNING *',
[req.userId, invoiceNumber, data]
);
await client.query('COMMIT');
res.status(201).json({ invoice: invoiceResponse(result.rows[0]) });
} catch (error) {
await client.query('ROLLBACK');
next(error);
} finally {
client.release();
}
});
app.put('/api/invoices/:id', requireAuth, async (req, res, next) => {
try {
const data = normalizeInvoiceData(req.body.data);
if (!data) {
res.status(400).json({ error: 'Neplatná data faktury.' });
return;
}
const result = await pool.query(
'UPDATE invoices SET invoice_data = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3 RETURNING *',
[data, req.params.id, req.userId]
);
if (!result.rowCount) {
res.status(404).json({ error: 'Faktura nebyla nalezena.' });
return;
}
res.json({ invoice: invoiceResponse(result.rows[0]) });
} catch (error) {
next(error);
}
});
app.delete('/api/invoices/:id', requireAuth, async (req, res, next) => {
try {
await pool.query('DELETE FROM invoices WHERE id = $1 AND user_id = $2', [req.params.id, req.userId]);
res.status(204).end();
} catch (error) {
next(error);
}
});
app.delete('/api/invoices', requireAuth, async (req, res, next) => {
try {
await pool.query('DELETE FROM invoices WHERE user_id = $1', [req.userId]);
await pool.query('DELETE FROM invoice_counters WHERE user_id = $1', [req.userId]);
res.status(204).end();
} catch (error) {
next(error);
}
});
const pdfText = (value) => String(value || 'Neuvedeno');
const formatCzechNumber = (value) => Number(value || 0).toLocaleString('cs-CZ', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
const normalizeIban = (value) => String(value || '').replace(/\s+/g, '').toUpperCase();
const domesticAccountToIban = (accountNumber) => {
const match = String(accountNumber || '').replace(/\s+/g, '')
.match(/^(?:(\d{1,6})-)?(\d{1,10})\/(\d{4})$/);
if (!match) {
return '';
}
const bban = `${match[3]}${(match[1] || '').padStart(6, '0')}${match[2].padStart(10, '0')}`;
const remainder = `${bban}123500`.split('').reduce(
(value, digit) => (value * 10 + Number(digit)) % 97,
0
);
return `CZ${String(98 - remainder).padStart(2, '0')}${bban}`;
};
const buildPaymentQrPayload = (payment, total, invoiceNumber = '') => {
const account = normalizeIban(payment.iban) || domesticAccountToIban(payment.cisloUctu);
if (!/^([A-Z]{2})\d{2}[A-Z0-9]{10,32}$/.test(account)) {
return '';
}
return [
'SPD*1.0',
`ACC:${account}`,
`AM:${Number(total || 0).toFixed(2)}`,
'CC:CZK',
invoiceNumber && `X-VS:${invoiceNumber.replace(/\D/g, '').slice(-10)}`,
payment.swift && `X-SWIFT:${normalizeIban(payment.swift)}`
].filter(Boolean).join('*');
};
const appendInvoicePdf = async (archive, invoice) => {
const data = invoice.invoice_data || {};
const supplier = data.supplier || {};
const customer = data.customer || {};
const payment = data.payment || {};
const supplierName = supplier.typSubjektu === 'spolecnost'
? supplier.nazevSpolecnosti
: [supplier.jmeno, supplier.prijmeni].filter(Boolean).join(' ');
const customerName = customer.typSubjektu === 'spolecnost'
? customer.nazevSpolecnosti
: [customer.jmeno, customer.prijmeni].filter(Boolean).join(' ');
const document = new PDFDocument({ size: 'A4', margin: 50 });
const regularFont = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
const boldFont = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
const qrPayload = buildPaymentQrPayload(payment, data.total, invoice.invoice_number);
const qrBuffer = qrPayload ? await QRCode.toBuffer(qrPayload, {
type: 'png',
width: 180,
margin: 1
}) : null;
const pageWidth = document.page.width - document.page.margins.left - document.page.margins.right;
const navy = '#183b56';
const blue = '#2b6f8f';
const line = '#d8d4cc';
const lightBlue = '#f4f7fb';
const warm = '#fffaf0';
const bodyText = '#17202a';
const muted = '#65717d';
const address = (profile) => [
profile.uliceCp,
[profile.psc, profile.mesto].filter(Boolean).join(', ')
].filter(Boolean).join(', ');
const identity = (profile, name) => [
name || 'Neuvedeno',
profile.ico ? `IČO: ${profile.ico}` : '',
address(profile)
].filter(Boolean);
const partyWidth = (pageWidth - 24) / 2;
const customerX = document.page.margins.left + partyWidth + 24;
archive.append(document, { name: `${invoice.invoice_number}.pdf` });
const headerY = document.y;
document.fillColor(navy).font(boldFont).fontSize(25).text('Faktura', document.page.margins.left, headerY);
document.fillColor(navy).font(boldFont).fontSize(16).text(invoice.invoice_number, customerX, headerY, {
width: partyWidth,
align: 'right'
});
document.y = headerY + 40;
const partyTop = document.y;
const drawParty = (x, title, profile, name) => {
const lines = identity(profile, name);
document.roundedRect(x, partyTop, partyWidth, 86, 4).fillAndStroke('#f4f7fb', line);
document.fillColor(navy).font(boldFont).fontSize(11).text(title, x + 10, partyTop + 10, { width: partyWidth - 20 });
document.fillColor(bodyText).font(regularFont).fontSize(9).text(lines.join('\n'), x + 10, partyTop + 30, { width: partyWidth - 20, lineGap: 3 });
};
drawParty(document.page.margins.left, 'Dodavatel', supplier, supplierName);
drawParty(customerX, 'Odběratel', customer, customerName);
document.y = partyTop + 100;
const tableTop = document.y + 12;
const columns = [0, pageWidth * 0.52, pageWidth * 0.68, pageWidth * 0.82, pageWidth];
const rowHeight = 25;
document.rect(document.page.margins.left, tableTop, pageWidth, rowHeight).fill('#eef1f3');
document.fillColor(navy).font(boldFont).fontSize(9);
['Položka', 'Množství', 'MJ', 'Cena za MJ'].forEach((heading, index) => {
document.text(heading, document.page.margins.left + columns[index] + 6, tableTop + 8, {
width: columns[index + 1] - columns[index] - 12
});
});
document.y = tableTop + rowHeight;
(data.items || []).forEach((item, index) => {
const rowTop = document.y;
document.rect(document.page.margins.left, rowTop, pageWidth, rowHeight)
.fill(index % 2 ? lightBlue : warm);
document.moveTo(document.page.margins.left, rowTop + rowHeight)
.lineTo(document.page.margins.left + pageWidth, rowTop + rowHeight)
.strokeColor(line).lineWidth(0.7).stroke();
document.fillColor(bodyText).font(regularFont).fontSize(9);
[pdfText(item.popis), formatCzechNumber(item.mnozstvi), pdfText(item.mernaJednotka), `${formatCzechNumber(item.cenaZaMj)}`]
.forEach((value, columnIndex) => document.text(value, document.page.margins.left + columns[columnIndex] + 6, rowTop + 8, {
width: columns[columnIndex + 1] - columns[columnIndex] - 12
}));
document.y = rowTop + rowHeight;
});
document.y += 18;
document.fillColor(navy).font(boldFont).fontSize(15)
.text(`Celková částka: ${formatCzechNumber(data.total)}`, document.page.margins.left, document.y, {
width: pageWidth,
align: 'right'
});
document.y += 35;
document.moveTo(document.page.margins.left, document.y).lineTo(document.page.margins.left + pageWidth, document.y)
.strokeColor(navy).lineWidth(1.5).stroke();
document.y += 12;
const paymentTop = document.y;
document.roundedRect(document.page.margins.left, paymentTop, pageWidth, 165, 4).fillAndStroke('#f4f7fb', line);
document.fillColor(navy).font(boldFont).fontSize(11).text('Platební údaje', document.page.margins.left + 10, paymentTop + 10);
document.fillColor(bodyText).font(regularFont).fontSize(10)
.text(`Číslo účtu: ${pdfText(payment.cisloUctu)}`, document.page.margins.left + 10, paymentTop + 35)
.text(`IBAN: ${pdfText(payment.iban)}`, document.page.margins.left + 10, paymentTop + 55)
.text(`SWIFT: ${pdfText(payment.swift)}`, document.page.margins.left + 10, paymentTop + 75);
if (qrBuffer) {
document.image(qrBuffer, document.page.margins.left + pageWidth - 155, paymentTop + 10, { width: 145, height: 145 });
}
document.end();
};
app.get('/api/invoices/export', requireAuth, async (req, res, next) => {
try {
const result = await pool.query(
'SELECT invoice_number, invoice_data FROM invoices WHERE user_id = $1 ORDER BY invoice_number',
[req.userId]
);
if (!result.rowCount) {
res.status(404).json({ error: 'Nejsou uložené žádné faktury k exportu.' });
return;
}
res.attachment('faktury.zip');
const archive = new archiver.ZipArchive({ zlib: { level: 9 } });
archive.on('error', next);
archive.pipe(res);
for (const invoice of result.rows) {
await appendInvoicePdf(archive, invoice);
}
archive.finalize();
} catch (error) {
next(error);
}
});
// specifying the folder with the static content - html+css
app.use(express.static(__dirname + '/static'));
@@ -10,4 +621,9 @@ app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '/static/index.html'));
});
app.use((error, req, res, next) => {
console.error(error);
res.status(500).json({ error: 'Na serveru došlo k chybě.' });
});
module.exports = app;