/ src / reports / report-orchestrator.js
report-orchestrator.js
  1  #!/usr/bin/env node
  2  
  3  /**
  4   * Report Orchestrator
  5   *
  6   * Orchestrates the full audit report pipeline:
  7   * 1. Fetch purchase record
  8   * 2. Find or create site record
  9   * 3. Capture full-page screenshot
 10   * 4. Refresh score via LLM
 11   * 5. Crop problem areas
 12   * 6. Generate PDF report
 13   * 7. Update purchase record
 14   */
 15  
 16  import { run, getOne } from '../utils/db.js';
 17  import { join, dirname } from 'path';
 18  import { fileURLToPath } from 'url';
 19  import { captureFullPage } from './full-page-capture.js';
 20  import { refreshScore } from './score-refresh.js';
 21  import { cropProblemAreas } from './problem-area-cropper.js';
 22  import { generateMockups } from './mockup-generator.js';
 23  import { generateReportHTML } from './html-report-template.js';
 24  import { renderReportPDF } from './html-to-pdf.js';
 25  import Logger from '../utils/logger.js';
 26  import { extractDomain } from '../utils/error-handler.js';
 27  import { setScoreJson } from '../utils/score-storage.js';
 28  import '../utils/load-env.js';
 29  
 30  const __filename = fileURLToPath(import.meta.url);
 31  const __dirname = dirname(__filename);
 32  const projectRoot = join(__dirname, '../..');
 33  
 34  const logger = new Logger('ReportOrchestrator');
 35  
 36  /**
 37   * Generate a complete audit report for a purchase
 38   * @param {number} purchaseId - Purchase ID
 39   * @param {Object} [options]
 40   * @param {'full'|'quick-fixes'} [options.variant='full'] - Report variant
 41   * @returns {Object} { reportPath, score, grade }
 42   */
 43  export async function generateAuditReportForPurchase(purchaseId, { variant = 'full' } = {}) {
 44    // 1. Fetch purchase
 45    const purchase = await getOne('SELECT * FROM purchases WHERE id = $1', [purchaseId]);
 46    if (!purchase) {
 47      throw new Error(`Purchase ${purchaseId} not found`);
 48    }
 49  
 50    const url = purchase.landing_page_url;
 51    const domain = extractDomain(url);
 52  
 53    logger.info(`Generating report for purchase #${purchaseId}: ${domain}`);
 54  
 55    // 2. Find or create site record
 56    let siteId = purchase.site_id;
 57    if (!siteId) {
 58      // Check if site exists by domain or URL
 59      const existingSite = await getOne(
 60        'SELECT id FROM sites WHERE landing_page_url = $1 OR domain = $2',
 61        [url, domain]
 62      );
 63  
 64      if (existingSite) {
 65        siteId = existingSite.id;
 66      } else {
 67        // Create new site record
 68        const result = await run(
 69          `INSERT INTO sites (domain, landing_page_url, keyword, status, country_code)
 70           VALUES ($1, $2, $3, 'found', $4)
 71           RETURNING id`,
 72          [domain, url, 'audit-report-purchase', purchase.country_code]
 73        );
 74        siteId = result.lastInsertRowid;
 75      }
 76  
 77      // Link site to purchase
 78      await run('UPDATE purchases SET site_id = $1 WHERE id = $2', [siteId, purchaseId]);
 79    }
 80  
 81    // 3. Full-page capture (initial pass — no selectors yet, we don't know them until step 4)
 82    logger.info('Capturing full-page screenshot...');
 83    const captureResult = await captureFullPage(url);
 84  
 85    // 4. Score refresh via Opus with extended thinking
 86    logger.info('Refreshing score via Opus...');
 87    const scoreJson = await refreshScore({
 88      url,
 89      fullPageBuffer: captureResult.fullPageBuffer,
 90      aboveFoldBuffer: captureResult.aboveFoldBuffer,
 91      htmlContent: captureResult.htmlContent,
 92      httpHeaders: captureResult.httpHeaders,
 93      siteId,
 94      countryCode: purchase.country_code,
 95    });
 96  
 97    const score = scoreJson.overall_calculation?.conversion_score || 0;
 98    const grade = scoreJson.overall_calculation?.letter_grade || 'N/A';
 99  
100    // Quick Fixes variant: keep only the 5 worst-scoring factors and trim related sections
101    if (variant === 'quick-fixes') {
102      logger.info('Quick Fixes variant — filtering to top 5 worst factors');
103      const factors = scoreJson.factor_scores || {};
104      const sorted = Object.entries(factors).sort((a, b) => (a[1].score ?? 10) - (b[1].score ?? 10));
105      scoreJson.factor_scores = Object.fromEntries(sorted.slice(0, 5));
106      if (scoreJson.problem_areas) scoreJson.problem_areas = scoreJson.problem_areas.slice(0, 5);
107      if (scoreJson.strategic_recommendations) scoreJson.strategic_recommendations = scoreJson.strategic_recommendations.slice(0, 7);
108      if (scoreJson.report_narratives) delete scoreJson.report_narratives.action_plan_quarter;
109    }
110  
111    // 5. Generate before/after mockups for each problem area.
112    // Opens the live page again, screenshots each selector, applies mockup_changes,
113    // then screenshots the "after" state. Falls back to y% crop if selector fails.
114    const problemAreas = scoreJson.problem_areas || [];
115    let problemCrops;
116  
117    const hasMockupChanges = problemAreas.some(a => a.mockup_changes?.length > 0);
118    if (hasMockupChanges) {
119      logger.info('Generating before/after mockups...');
120      try {
121        problemCrops = await generateMockups(url, problemAreas, captureResult.fullPageBuffer);
122      } catch (err) {
123        logger.warn(`Mockup generation failed — falling back to static crops: ${err.message}`);
124        problemCrops = await cropProblemAreas(captureResult.fullPageBuffer, problemAreas);
125      }
126    } else {
127      // Legacy path: no mockup_changes in score_json — use static crop
128      logger.info('No mockup_changes found — using static crops...');
129      problemCrops = await cropProblemAreas(captureResult.fullPageBuffer, problemAreas);
130    }
131  
132    // 6. Generate HTML report and render to PDF
133    const reportDir = join(projectRoot, 'reports', 'purchases', String(purchaseId));
134    const reportFilename = variant === 'quick-fixes' ? 'quick-fixes-report.pdf' : 'audit-report.pdf';
135    const reportPath = join(reportDir, reportFilename);
136  
137    logger.info('Generating HTML report...');
138    const visionUsed =
139      scoreJson.scoring_method === 'vision' || scoreJson.scoring_method === 'vision+html';
140    const html = generateReportHTML({
141      domain,
142      url,
143      scoreJson,
144      aboveFoldBuffer: captureResult.aboveFoldBuffer,
145      problemCrops,
146      narrativeSections: scoreJson.report_narratives,
147      reportDate: new Date(),
148      visionUsed,
149      variant,
150    });
151  
152    logger.info('Rendering PDF...');
153    await renderReportPDF({ html, outputPath: reportPath });
154  
155    // 7. Update purchase and site records
156    await run(
157      `UPDATE purchases
158       SET status = 'report_generated', report_path = $1, report_score = $2, report_grade = $3, updated_at = NOW()
159       WHERE id = $4`,
160      [reportPath, score, grade, purchaseId]
161    );
162  
163    setScoreJson(siteId, JSON.stringify(scoreJson));
164    await run(
165      `UPDATE sites
166       SET score = $1, grade = $2, score_json = '{"_fs":true}', scored_at = NOW(), updated_at = NOW()
167       WHERE id = $3`,
168      [score, grade, siteId]
169    );
170  
171    logger.success(`Report generated for purchase #${purchaseId}: ${score} (${grade})`);
172  
173    return { reportPath, score, grade };
174  }
175  
176  // CLI
177  if (import.meta.url === `file://${process.argv[1]}`) {
178    const purchaseId = parseInt(process.argv[2]);
179  
180    if (!purchaseId) {
181      console.log('Usage: node src/reports/report-orchestrator.js <purchase_id>');
182      process.exit(1);
183    }
184  
185    generateAuditReportForPurchase(purchaseId)
186      .then(result => {
187        console.log('Report generated:', result);
188        process.exit(0);
189      })
190      .catch(error => {
191        logger.error('Report generation failed:', error);
192        process.exit(1);
193      });
194  }