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.id, q.quote_number, q.customer_id, q.quote_date, q.tax_exempt, q.tax_rate, q.subtotal, q.tax_amount, q.total, q.has_tbd, q.tbd_note, 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; 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.3in', 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 = '
BA
'; 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 = `Logo`; } catch (err) { console.error('Error reading logo file:', err); } break; } } let itemsHTML = ''; quote.items.forEach(item => { itemsHTML += ` ${item.quantity} ${item.description} ${item.rate} ${item.amount} `; }); // Add sales tax row if not tax exempt if (!quote.tax_exempt) { itemsHTML += ` Sales Tax ${quote.tax_rate}% ${formatCurrency(quote.tax_amount)} `; } // Total row const totalDisplay = quote.has_tbd ? `$${formatCurrency(quote.total)}*` : `$${formatCurrency(quote.total)}`; itemsHTML += ` This quote is valid for 14 days. We appreciate your business. Total ${totalDisplay} `; const tbdNote = quote.has_tbd && quote.tbd_note ? `

*${quote.tbd_note}

` : quote.has_tbd ? `

*Total excludes items marked as TBD which will be determined based on actual requirements.

` : ''; return ` Quote - Bay Area Affiliates, Inc.
${logoHTML}

Bay Area Affiliates, Inc.

1001 Blucher Street
Corpus Christi, Texas 78401

Providing IT Services and Support in South Texas Since 1996
Phone:
(361) 765-8400
(361) 765-8401
(361) 232-6578
Email:
support@bayarea-cc.com
Quote For:
${quote.customer_name}
${quote.street}
${quote.city}, ${quote.state} ${quote.zip_code}
QUOTE # ACCOUNT NO. DATE
${quote.quote_number} ${quote.account_number || ''} ${formatDate(quote.quote_date)}
${itemsHTML}
QTY DESCRIPTION RATE AMOUNT
${tbdNote}
`; } // 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); });