/ src / reports / cro-report-generator.js
cro-report-generator.js
  1  #!/usr/bin/env node
  2  
  3  /**
  4   * CRO Report Generator
  5   * Generates PDF reports from site scoring data
  6   * Requires: npm install pdfkit
  7   */
  8  
  9  import PDFDocument from 'pdfkit';
 10  import { createWriteStream, existsSync, mkdirSync } from 'fs';
 11  import { join, dirname } from 'path';
 12  import { fileURLToPath } from 'url';
 13  import { run, getOne } from '../utils/db.js';
 14  import Logger from '../utils/logger.js';
 15  import { getScoreDataWithFallback } from '../utils/score-storage.js';
 16  import '../utils/load-env.js';
 17  
 18  const __filename = fileURLToPath(import.meta.url);
 19  const __dirname = dirname(__filename);
 20  const projectRoot = join(__dirname, '../..');
 21  
 22  const logger = new Logger('CROReport');
 23  
 24  // Report output directory
 25  const REPORTS_DIR = join(projectRoot, 'reports');
 26  
 27  // Ensure reports directory exists
 28  if (!existsSync(REPORTS_DIR)) {
 29    mkdirSync(REPORTS_DIR, { recursive: true });
 30  }
 31  
 32  /**
 33   * Generate CRO report PDF
 34   * @param {number} siteId - Site ID to generate report for
 35   * @param {number} conversationId - Conversation ID for tracking
 36   * @returns {Promise<string>} Path to generated PDF
 37   */
 38  export async function generateReport(siteId, conversationId) {
 39    logger.info(`Generating CRO report for site ${siteId}`);
 40  
 41    // Fetch site data
 42    const site = await getOne(
 43      `SELECT id, domain, landing_page_url, keyword, score, grade, scored_at
 44       FROM sites
 45       WHERE id = $1`,
 46      [siteId]
 47    );
 48  
 49    if (!site) {
 50      throw new Error(`Site ${siteId} not found`);
 51    }
 52  
 53    // Parse scoring data (filesystem first, DB fallback)
 54    const scoreData = getScoreDataWithFallback(siteId, site);
 55    if (!scoreData) {
 56      throw new Error(`Site ${siteId} has no scoring data`);
 57    }
 58  
 59    // Generate PDF filename
 60    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
 61    const filename = `cro-report-${site.domain}-${timestamp}.pdf`;
 62    const filepath = join(REPORTS_DIR, filename);
 63  
 64    // Create PDF
 65    await createReportPDF(site, scoreData, filepath);
 66  
 67    // Update message with report URL
 68    await run(
 69      `UPDATE messages
 70       SET report_url = $1,
 71           status = 'report_delivered'
 72       WHERE id = $2`,
 73      [filename, conversationId]
 74    );
 75  
 76    logger.success(`Generated report: ${filename}`);
 77  
 78    return filepath;
 79  }
 80  
 81  /**
 82   * Create PDF document with CRO analysis
 83   */
 84  // eslint-disable-next-line require-await -- Wraps Promise-based PDF generation
 85  async function createReportPDF(site, scoreData, filepath) {
 86    return new Promise((resolve, reject) => {
 87      try {
 88        const doc = new PDFDocument({ size: 'A4', margin: 50 });
 89        const stream = createWriteStream(filepath);
 90  
 91        doc.pipe(stream);
 92  
 93        // Header
 94        doc
 95          .fontSize(24)
 96          .font('Helvetica-Bold')
 97          .text('Conversion Rate Optimization Report', { align: 'center' });
 98  
 99        doc.moveDown(0.5);
100  
101        doc.fontSize(16).font('Helvetica').fillColor('#666').text(site.domain, { align: 'center' });
102  
103        doc.moveDown(1);
104  
105        // Summary Section
106        addSection(doc, 'Executive Summary');
107  
108        doc
109          .fontSize(12)
110          .font('Helvetica')
111          .fillColor('#000')
112          .text(`Website: ${site.landing_page_url}`);
113  
114        doc.text(`Analysis Date: ${new Date(site.scored_at).toLocaleDateString()}`);
115  
116        doc.moveDown(0.5);
117  
118        // Overall Score Box
119        doc.rect(50, doc.y, 495, 80).fillAndStroke('#f0f0f0', '#333').fillColor('#000');
120  
121        const gradeColor = getGradeColor(site.grade);
122  
123        doc
124          .fontSize(48)
125          .font('Helvetica-Bold')
126          .fillColor(gradeColor)
127          .text(site.grade, 70, doc.y - 70, { width: 100, align: 'center' });
128  
129        doc
130          .fontSize(14)
131          .font('Helvetica')
132          .fillColor('#000')
133          .text(`Overall Score: ${site.score}/100`, 200, doc.y - 105);
134  
135        doc.text(`Conversion Potential: ${getConversionPotential(site.score)}`, 200, doc.y + 15);
136  
137        doc.moveDown(4);
138  
139        // Category Scores
140        if (scoreData.category_scores) {
141          addSection(doc, 'Category Analysis');
142  
143          const categories = scoreData.category_scores;
144  
145          addCategoryScore(doc, 'Above-the-Fold Experience', categories.above_fold_score);
146          addCategoryScore(doc, 'Call-to-Action (CTA) Effectiveness', categories.cta_score);
147          addCategoryScore(doc, 'Trust & Credibility Signals', categories.trust_score);
148          addCategoryScore(doc, 'Mobile Optimization', categories.mobile_score);
149          addCategoryScore(doc, 'User Experience (UX)', categories.ux_score);
150  
151          doc.moveDown(1);
152        }
153  
154        // Key Findings
155        if (scoreData.major_issues || scoreData.strengths) {
156          addSection(doc, 'Key Findings');
157  
158          if (scoreData.major_issues && scoreData.major_issues.length > 0) {
159            doc.fontSize(12).font('Helvetica-Bold').fillColor('#d32f2f').text('Critical Issues:');
160  
161            doc.font('Helvetica').fillColor('#000');
162  
163            scoreData.major_issues.slice(0, 5).forEach((issue, index) => {
164              doc.text(`${index + 1}. ${issue}`, { indent: 20 });
165            });
166  
167            doc.moveDown(1);
168          }
169  
170          if (scoreData.strengths && scoreData.strengths.length > 0) {
171            doc.fontSize(12).font('Helvetica-Bold').fillColor('#388e3c').text('Strengths:');
172  
173            doc.font('Helvetica').fillColor('#000');
174  
175            scoreData.strengths.slice(0, 5).forEach((strength, index) => {
176              doc.text(`${index + 1}. ${strength}`, { indent: 20 });
177            });
178  
179            doc.moveDown(1);
180          }
181        }
182  
183        // Recommendations
184        if (scoreData.recommendations || scoreData.quick_wins) {
185          doc.addPage();
186          addSection(doc, 'Recommendations');
187  
188          if (scoreData.quick_wins && scoreData.quick_wins.length > 0) {
189            doc
190              .fontSize(12)
191              .font('Helvetica-Bold')
192              .fillColor('#1976d2')
193              .text('Quick Wins (High Impact, Low Effort):');
194  
195            doc.font('Helvetica').fillColor('#000');
196  
197            scoreData.quick_wins.forEach((win, index) => {
198              doc.text(`${index + 1}. ${win}`, { indent: 20 });
199            });
200  
201            doc.moveDown(1);
202          }
203  
204          if (scoreData.recommendations && scoreData.recommendations.length > 0) {
205            doc.fontSize(12).font('Helvetica-Bold').text('Strategic Recommendations:');
206  
207            doc.font('Helvetica');
208  
209            scoreData.recommendations.slice(0, 10).forEach((rec, index) => {
210              doc.text(`${index + 1}. ${rec}`, { indent: 20 });
211            });
212          }
213        }
214  
215        // Footer
216        doc
217          .fontSize(10)
218          .fillColor('#666')
219          .text(
220            `Report generated on ${new Date().toLocaleDateString()} | Confidential`,
221            50,
222            doc.page.height - 50,
223            {
224              align: 'center',
225            }
226          );
227  
228        // Finalize PDF
229        doc.end();
230  
231        stream.on('finish', () => {
232          logger.info(`PDF written to ${filepath}`);
233          resolve(filepath);
234        });
235  
236        stream.on('error', reject);
237      } catch (error) {
238        reject(error);
239      }
240    });
241  }
242  
243  /**
244   * Add section header to PDF
245   */
246  function addSection(doc, title) {
247    doc.fontSize(18).font('Helvetica-Bold').fillColor('#333').text(title);
248  
249    doc.moveDown(0.5);
250    doc.strokeColor('#ddd').lineWidth(2).moveTo(50, doc.y).lineTo(545, doc.y).stroke();
251    doc.moveDown(0.5);
252  }
253  
254  /**
255   * Add category score bar to PDF
256   */
257  function addCategoryScore(doc, label, score) {
258    const barWidth = 400;
259    const barHeight = 20;
260    const fillWidth = (score / 100) * barWidth;
261  
262    doc.fontSize(11).font('Helvetica').fillColor('#000').text(label);
263  
264    const yPos = doc.y + 5;
265  
266    // Background bar
267    doc.rect(50, yPos, barWidth, barHeight).fillAndStroke('#e0e0e0', '#999');
268  
269    // Filled bar
270    const barColor = getScoreColor(score);
271    doc.rect(50, yPos, fillWidth, barHeight).fillAndStroke(barColor, '#999');
272  
273    // Score text
274    doc
275      .fontSize(11)
276      .font('Helvetica-Bold')
277      .fillColor('#000')
278      .text(`${score}/100`, 460, yPos + 3);
279  
280    doc.moveDown(1.5);
281  }
282  
283  /**
284   * Get color for score
285   */
286  function getScoreColor(score) {
287    if (score >= 85) return '#4caf50'; // Green
288    if (score >= 70) return '#8bc34a'; // Light green
289    if (score >= 50) return '#ffc107'; // Amber
290    if (score >= 30) return '#ff9800'; // Orange
291    return '#f44336'; // Red
292  }
293  
294  /**
295   * Get color for grade
296   */
297  function getGradeColor(grade) {
298    if (grade?.startsWith('A')) return '#4caf50';
299    if (grade?.startsWith('B')) return '#8bc34a';
300    if (grade?.startsWith('C')) return '#ffc107';
301    if (grade?.startsWith('D')) return '#ff9800';
302    return '#f44336';
303  }
304  
305  /**
306   * Get conversion potential description
307   */
308  function getConversionPotential(score) {
309    if (score >= 90) return 'Excellent - Minor optimizations only';
310    if (score >= 82) return 'Good - Some improvement opportunities';
311    if (score >= 70) return 'Fair - Significant improvements needed';
312    if (score >= 50) return 'Poor - Major conversion issues';
313    return 'Critical - Urgent optimization required';
314  }
315  
316  // CLI functionality
317  if (import.meta.url === `file://${process.argv[1]}`) {
318    const command = process.argv[2];
319  
320    if (command === 'generate') {
321      const siteId = parseInt(process.argv[3]);
322      const conversationId = parseInt(process.argv[4]);
323  
324      if (!siteId || !conversationId) {
325        console.error(
326          'Usage: node src/reports/cro-report-generator.js generate <site_id> <conversation_id>'
327        );
328        process.exit(1);
329      }
330  
331      generateReport(siteId, conversationId)
332        .then(filepath => {
333          console.log(`\n✅ Report Generated\n`);
334          console.log(`Location: ${filepath}\n`);
335          process.exit(0);
336        })
337        .catch(error => {
338          logger.error('Report generation failed', error);
339          process.exit(1);
340        });
341    } else {
342      console.log('CRO Report Generator');
343      console.log('');
344      console.log('Usage:');
345      console.log('  generate <site_id> <conversation_id>  - Generate PDF report');
346      console.log('');
347      console.log('Examples:');
348      console.log('  node src/reports/cro-report-generator.js generate 123 456');
349      console.log('');
350      console.log('Note: Requires pdfkit package (npm install pdfkit)\n');
351      process.exit(1);
352    }
353  }
354  
355  // Exported for testing
356  export { getScoreColor, getGradeColor, getConversionPotential };
357  
358  export default {
359    generateReport,
360  };