quote-system/server.js

829 lines
25 KiB
JavaScript

const express = require('express');
const { Pool } = require('pg');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const puppeteer = require('puppeteer-core');
const app = express();
const PORT = process.env.PORT || 3000;
// Database configuration
const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'quoteuser',
password: process.env.DB_PASSWORD || 'quotepass123',
database: process.env.DB_NAME || 'quotedb'
});
// Middleware
app.use(express.json());
app.use(express.static('public'));
app.use('/uploads', express.static('uploads'));
// Configure multer for logo upload
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = './uploads';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
cb(null, 'logo_' + Date.now() + path.extname(file.originalname));
}
});
const upload = multer({
storage: storage,
fileFilter: (req, file, cb) => {
const filetypes = /jpeg|jpg|png|gif/;
const extname = filetypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = filetypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
}
cb(new Error('Only image files are allowed!'));
}
});
// Generate next quote number
async function generateQuoteNumber() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const prefix = `${year}-${month}-`;
const result = await pool.query(
`SELECT quote_number FROM quotes
WHERE quote_number LIKE $1
ORDER BY quote_number DESC
LIMIT 1`,
[prefix + '%']
);
let nextNumber = 1;
if (result.rows.length > 0) {
const lastNumber = parseInt(result.rows[0].quote_number.split('-')[2]);
nextNumber = lastNumber + 1;
}
return prefix + String(nextNumber).padStart(4, '0');
}
// API Routes
// Customers
app.get('/api/customers', async (req, res) => {
try {
const result = await pool.query(
'SELECT * FROM customers ORDER BY name'
);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
app.get('/api/customers/:id', async (req, res) => {
try {
const result = await pool.query(
'SELECT * FROM customers WHERE id = $1',
[req.params.id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Customer not found' });
}
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
app.post('/api/customers', async (req, res) => {
const { name, street, city, state, zip_code, account_number } = req.body;
try {
const result = await pool.query(
`INSERT INTO customers (name, street, city, state, zip_code, account_number)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[name, street, city, state, zip_code, account_number]
);
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
app.put('/api/customers/:id', async (req, res) => {
const { name, street, city, state, zip_code, account_number } = req.body;
try {
const result = await pool.query(
`UPDATE customers
SET name = $1, street = $2, city = $3, state = $4,
zip_code = $5, account_number = $6, updated_at = CURRENT_TIMESTAMP
WHERE id = $7
RETURNING *`,
[name, street, city, state, zip_code, account_number, req.params.id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Customer not found' });
}
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
app.delete('/api/customers/:id', async (req, res) => {
try {
const result = await pool.query(
'DELETE FROM customers WHERE id = $1 RETURNING id',
[req.params.id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Customer not found' });
}
res.json({ message: 'Customer deleted successfully' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// Quotes
app.get('/api/quotes', async (req, res) => {
try {
const result = await pool.query(
`SELECT q.*, c.name as customer_name
FROM quotes q
LEFT JOIN customers c ON q.customer_id = c.id
ORDER BY q.quote_number DESC`
);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// Get next quote number (MUST be before /:id route)
app.get('/api/quotes/next-number', async (req, res) => {
try {
const quoteNumber = await generateQuoteNumber();
res.json({ quote_number: quoteNumber });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Error generating quote number' });
}
});
app.get('/api/quotes/:id', async (req, res) => {
try {
const quoteResult = await pool.query(
`SELECT q.*, c.*
FROM quotes q
LEFT JOIN customers c ON q.customer_id = c.id
WHERE q.id = $1`,
[req.params.id]
);
if (quoteResult.rows.length === 0) {
return res.status(404).json({ error: 'Quote not found' });
}
const itemsResult = await pool.query(
`SELECT * FROM quote_items
WHERE quote_id = $1
ORDER BY item_order`,
[req.params.id]
);
const quote = quoteResult.rows[0];
quote.items = itemsResult.rows;
res.json(quote);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
app.post('/api/quotes', async (req, res) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { customer_id, quote_date, tax_exempt, items, tbd_note } = req.body;
// Generate quote number
const quote_number = await generateQuoteNumber();
// Calculate totals
let subtotal = 0;
let has_tbd = false;
items.forEach(item => {
if (item.amount === 'TBD' || item.is_tbd) {
has_tbd = true;
} else {
const amount = parseFloat(item.amount) || 0;
subtotal += amount;
}
});
const tax_rate = tax_exempt ? 0 : 8.25;
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
const total = subtotal + tax_amount;
// Insert quote
const quoteResult = await client.query(
`INSERT INTO quotes (quote_number, customer_id, quote_date, tax_exempt,
tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[quote_number, customer_id, quote_date, tax_exempt, tax_rate,
subtotal, tax_amount, total, has_tbd, tbd_note]
);
const quote_id = quoteResult.rows[0].id;
// Insert items
for (let i = 0; i < items.length; i++) {
const item = items[i];
await client.query(
`INSERT INTO quote_items (quote_id, quantity, description, rate, amount, is_tbd, item_order)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[quote_id, item.quantity, item.description, item.rate,
item.amount, item.is_tbd || false, i]
);
}
await client.query('COMMIT');
res.json(quoteResult.rows[0]);
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
res.status(500).json({ error: 'Database error' });
} finally {
client.release();
}
});
app.put('/api/quotes/:id', async (req, res) => {
console.log('PUT /api/quotes/:id called with id:', req.params.id);
console.log('Request body:', JSON.stringify(req.body, null, 2));
const client = await pool.connect();
try {
await client.query('BEGIN');
console.log('Transaction started');
const { customer_id, quote_date, tax_exempt, items, tbd_note } = req.body;
// Calculate totals
let subtotal = 0;
let has_tbd = false;
items.forEach(item => {
if (item.amount === 'TBD' || item.is_tbd) {
has_tbd = true;
} else {
const amount = parseFloat(item.amount) || 0;
subtotal += amount;
}
});
const tax_rate = tax_exempt ? 0 : 8.25;
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
const total = subtotal + tax_amount;
console.log('Calculated totals:', { subtotal, tax_amount, total, has_tbd });
// Update quote
console.log('Updating quote with id:', req.params.id);
const quoteResult = await client.query(
`UPDATE quotes
SET customer_id = $1, quote_date = $2, tax_exempt = $3,
tax_rate = $4, subtotal = $5, tax_amount = $6, total = $7,
has_tbd = $8, tbd_note = $9, updated_at = CURRENT_TIMESTAMP
WHERE id = $10
RETURNING *`,
[customer_id, quote_date, tax_exempt, tax_rate,
subtotal, tax_amount, total, has_tbd, tbd_note, parseInt(req.params.id)]
);
console.log('Quote updated, rows affected:', quoteResult.rows.length);
if (quoteResult.rows.length === 0) {
await client.query('ROLLBACK');
console.log('Quote not found, rolling back');
return res.status(404).json({ error: 'Quote not found' });
}
// Delete old items
console.log('Deleting old items');
await client.query('DELETE FROM quote_items WHERE quote_id = $1', [parseInt(req.params.id)]);
// Insert new items
console.log('Inserting', items.length, 'new items');
for (let i = 0; i < items.length; i++) {
const item = items[i];
await client.query(
`INSERT INTO quote_items (quote_id, quantity, description, rate, amount, is_tbd, item_order)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[parseInt(req.params.id), item.quantity, item.description, item.rate,
item.amount, item.is_tbd || false, i]
);
}
console.log('Committing transaction');
await client.query('COMMIT');
console.log('PUT request successful, sending response');
res.json(quoteResult.rows[0]);
} catch (err) {
await client.query('ROLLBACK');
console.error('PUT Error:', err);
res.status(500).json({ error: 'Database error: ' + err.message });
} finally {
client.release();
}
});
app.delete('/api/quotes/:id', async (req, res) => {
try {
const result = await pool.query(
'DELETE FROM quotes WHERE id = $1 RETURNING id',
[req.params.id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Quote not found' });
}
res.json({ message: 'Quote deleted successfully' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// Upload logo
app.post('/api/upload-logo', upload.single('logo'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// Save as "current_logo" for easy access
const logoPath = path.join(__dirname, 'uploads', 'current_logo' + path.extname(req.file.filename));
fs.renameSync(req.file.path, logoPath);
res.json({
filename: 'current_logo' + path.extname(req.file.filename),
path: `/uploads/current_logo${path.extname(req.file.filename)}`
});
});
// Get logo info
app.get('/api/logo-info', (req, res) => {
const uploadsDir = path.join(__dirname, 'uploads');
const possibleExtensions = ['.png', '.jpg', '.jpeg', '.gif'];
for (const ext of possibleExtensions) {
const logoPath = path.join(uploadsDir, 'current_logo' + ext);
if (fs.existsSync(logoPath)) {
return res.json({
hasLogo: true,
logoPath: `/uploads/current_logo${ext}`
});
}
}
res.json({ hasLogo: false });
});
// Generate PDF
app.post('/api/quotes/:id/pdf', async (req, res) => {
let browser;
try {
const quoteResult = await pool.query(
`SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
FROM quotes q
LEFT JOIN customers c ON q.customer_id = c.id
WHERE q.id = $1`,
[req.params.id]
);
if (quoteResult.rows.length === 0) {
return res.status(404).json({ error: 'Quote not found' });
}
const itemsResult = await pool.query(
`SELECT * FROM quote_items
WHERE quote_id = $1
ORDER BY item_order`,
[req.params.id]
);
const quote = quoteResult.rows[0];
quote.items = itemsResult.rows;
// Generate HTML for PDF
const html = generateQuoteHTML(quote);
console.log('Starting PDF generation for quote', quote.quote_number);
// Generate PDF with Puppeteer
browser = await puppeteer.launch({
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium-browser',
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-software-rasterizer',
'--disable-extensions'
]
});
console.log('Browser launched, creating page...');
const page = await browser.newPage();
await page.setContent(html, {
waitUntil: 'networkidle0',
timeout: 30000
});
console.log('Content set, generating PDF...');
const pdf = await page.pdf({
format: 'Letter',
printBackground: true,
preferCSSPageSize: false,
displayHeaderFooter: false,
margin: {
top: '0.5in',
right: '0.5in',
bottom: '0.5in',
left: '0.5in'
}
});
await browser.close();
browser = null;
console.log('PDF generated successfully, size:', pdf.length, 'bytes');
// PDF header is correct (first bytes are 0x25 0x50 0x44 0x46 = %PDF)
// No need to validate, Chromium always generates valid PDFs
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Length', pdf.length);
res.setHeader('Content-Disposition', `attachment; filename="Quote_${quote.quote_number}.pdf"`);
res.end(pdf, 'binary');
} catch (err) {
console.error('PDF Generation Error:', err);
if (browser) {
try {
await browser.close();
} catch (closeErr) {
console.error('Error closing browser:', closeErr);
}
}
res.status(500).json({ error: 'Error generating PDF: ' + err.message });
}
});
function generateQuoteHTML(quote) {
const formatCurrency = (amount) => {
if (amount === 'TBD') return 'TBD';
return parseFloat(amount).toFixed(2);
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;
};
// Check for logo file
let logoHTML = '<div style="width: 40px; height: 40px; background-color: #1e40af; border-radius: 4px; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 18px;">BA</div>';
const uploadsDir = path.join(__dirname, 'uploads');
const possibleExtensions = ['.png', '.jpg', '.jpeg', '.gif'];
for (const ext of possibleExtensions) {
const logoPath = path.join(uploadsDir, 'current_logo' + ext);
if (fs.existsSync(logoPath)) {
try {
const logoBuffer = fs.readFileSync(logoPath);
const logoBase64 = logoBuffer.toString('base64');
const mimeType = ext === '.png' ? 'image/png' : 'image/jpeg';
logoHTML = `<img class="logo-size" src="data:${mimeType};base64,${logoBase64}" alt="Logo">`;
} catch (err) {
console.error('Error reading logo file:', err);
}
break;
}
}
let itemsHTML = '';
quote.items.forEach(item => {
itemsHTML += `
<tr>
<td class="qty">${item.quantity}</td>
<td class="description">${item.description}</td>
<td class="rate">${item.rate}</td>
<td class="amount">${item.amount}</td>
</tr>
`;
});
// Add sales tax row if not tax exempt
if (!quote.tax_exempt) {
itemsHTML += `
<tr>
<td class="qty"></td>
<td class="description">Sales Tax</td>
<td class="rate">${quote.tax_rate}%</td>
<td class="amount">${formatCurrency(quote.tax_amount)}</td>
</tr>
`;
}
// Total row
const totalDisplay = quote.has_tbd ? `$${formatCurrency(quote.total)}*` : `$${formatCurrency(quote.total)}`;
itemsHTML += `
<tr class="footer-row">
<td colspan="2" class="thank-you">This quote is valid for 14 days. We appreciate your business.</td>
<td class="total-label">Total</td>
<td class="total-amount">${totalDisplay}</td>
</tr>
`;
const tbdNote = quote.has_tbd && quote.tbd_note
? `<p style="font-size: 12px; margin-top: 10px;">*${quote.tbd_note}</p>`
: quote.has_tbd
? `<p style="font-size: 12px; margin-top: 10px;">*Total excludes items marked as TBD which will be determined based on actual requirements.</p>`
: '';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quote - Bay Area Affiliates, Inc.</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Times New Roman', Times, serif;
background-color: #f5f5f5;
}
.container {
max-width: 8.5in;
height:11in;
margin: 0 auto;
background-color: white;
padding: 40px;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 2px solid #333;
}
.company-info {
display: flex;
align-items: flex-start;
gap: 15px;
}
.logo {
width: 50px;
height: 50px;
}
.company-details h1 {
font-size: 16px;
font-weight: normal;
margin-bottom: 2px;
}
.company-details p {
font-size: 14px;
line-height: 1.4;
}
.tagline {
text-align: right;
font-style: italic;
font-size: 14px;
margin-bottom: 20px;
}
.contact-info {
text-align: right;
font-size: 13px;
line-height: 1.6;
}
.bill-to-section {
display: flex;
justify-content: space-between;
margin: 30px 0 60px 0;
}
.bill-to {
flex: 1;
}
.bill-to-label {
font-weight: bold;
margin-bottom: 5px;
}
.bill-to-address {
font-size: 14px;
line-height: 1.5;
}
.info-table {
border-collapse: collapse;
font-size: 13px;
margin-left: auto;
}
.info-table th {
background-color: #fff;
border: 1px solid #000;
padding: 8px;
font-weight: bold;
text-align: center;
}
.info-table td {
border: 1px solid #000;
padding: 8px;
text-align: center;
}
.items-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-size: 13px;
}
.items-table th {
background-color: #fff;
border: 1px solid #000;
padding: 8px;
font-weight: bold;
text-align: center;
}
.items-table td {
border: 1px solid #000;
padding: 10px;
vertical-align: top;
}
.items-table td.qty {
text-align: center;
width: 60px;
}
.items-table td.description {
text-align: left;
}
.items-table td.rate,
.items-table td.amount {
text-align: right;
width: 100px;
}
.footer-row td {
border: 1px solid #000;
padding: 10px;
}
.total-label {
text-align: right;
font-size: 18px;
font-weight: bold;
padding-right: 20px !important;
}
.total-amount {
text-align: right;
font-size: 18px;
font-weight: bold;
}
.thank-you {
font-size: 13px;
}
.logo-size{
height: 40px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="company-info">
${logoHTML}
<div class="company-details">
<h1>Bay Area Affiliates, Inc.</h1>
<p>1001 Blucher Street<br>
Corpus Christi, Texas 78401</p>
</div>
</div>
<div>
<div class="tagline">
<em>Providing IT Services and Support in South Texas Since 1996</em>
</div>
<div class="contact-info">
Phone:<br>
(361) 765-8400<br>
(361) 765-8401<br>
(361) 232-6578<br>
Email:<br>
support@bayarea-cc.com
</div>
</div>
</div>
<div class="bill-to-section">
<div class="bill-to">
<div class="bill-to-label">Quote For:</div>
<div class="bill-to-address">
${quote.customer_name}<br>
${quote.street}<br>
${quote.city}, ${quote.state} ${quote.zip_code}
</div>
</div>
<table class="info-table">
<thead>
<tr>
<th>QUOTE #</th>
<th>ACCOUNT NO.</th>
<th>DATE</th>
</tr>
</thead>
<tbody>
<tr>
<td>${quote.quote_number}</td>
<td>${quote.account_number || ''}</td>
<td>${formatDate(quote.quote_date)}</td>
</tr>
</tbody>
</table>
</div>
<table class="items-table">
<thead>
<tr>
<th>QTY</th>
<th>DESCRIPTION</th>
<th>RATE</th>
<th>AMOUNT</th>
</tr>
</thead>
<tbody>
${itemsHTML}
</tbody>
</table>
${tbdNote}
</div>
</body>
</html>`;
}
// Start server
app.listen(PORT, () => {
console.log(`Quote System running on port ${PORT}`);
});
// Graceful shutdown
process.on('SIGTERM', async () => {
await pool.end();
process.exit(0);
});