629 lines
24 KiB
JavaScript
629 lines
24 KiB
JavaScript
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)} Kč`]
|
|
.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)} Kč`, 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'));
|
|
|
|
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; |