generate-daily-report.js
1 #!/usr/bin/env node 2 3 /** 4 * Generate Daily Progress Report 5 * Collects git, database, and system data from last 24 hours and generates PDF report 6 */ 7 8 import { exec } from 'child_process'; 9 import { promisify } from 'util'; 10 import { join, dirname } from 'path'; 11 import { fileURLToPath } from 'url'; 12 import { readFileSync, existsSync, readdirSync } from 'fs'; 13 import { getAll, getOne, closePool } from '../src/utils/db.js'; 14 import dotenv from 'dotenv'; 15 import { generateDailyReport } from '../src/reports/daily-progress-html-generator.js'; 16 import Logger from '../src/utils/logger.js'; 17 18 dotenv.config(); 19 20 const execAsync = promisify(exec); 21 const logger = new Logger('DailyReportCLI'); 22 23 const __filename = fileURLToPath(import.meta.url); 24 const __dirname = dirname(__filename); 25 const projectRoot = join(__dirname, '..'); 26 27 // All MMO workspace repos (relative to ~/code/) 28 const MMO_REPOS = [ 29 { name: '333Method', path: join(projectRoot) }, 30 { name: '2Step', path: join(projectRoot, '..', '2Step') }, 31 { name: 'distributed-infra', path: join(projectRoot, '..', 'distributed-infra') }, 32 { name: 'mmo-platform', path: join(projectRoot, '..', 'mmo-platform') }, 33 ]; 34 35 /** 36 * Get git activity from last 24 hours across all MMO workspace repos 37 */ 38 async function getGitActivity() { 39 const allCommits = []; 40 let filesChanged = 0; 41 let linesAdded = 0; 42 let linesRemoved = 0; 43 44 for (const repo of MMO_REPOS) { 45 if (!existsSync(join(repo.path, '.git'))) continue; 46 try { 47 const { stdout: commitsRaw } = await execAsync( 48 'git log --since="24 hours ago" --pretty=format:"%h|%s|%ar"', 49 { cwd: repo.path } 50 ); 51 52 const repoCommits = commitsRaw 53 .split('\n') 54 .filter(line => line.trim()) 55 .map(line => { 56 const [hash, message, timeAgo] = line.split('|'); 57 return { hash, message, timeAgo, repo: repo.name }; 58 }); 59 allCommits.push(...repoCommits); 60 61 try { 62 const { stdout: diffStat } = await execAsync('git diff --stat HEAD@{24.hours.ago}..HEAD', { 63 cwd: repo.path, 64 timeout: 20000, 65 }); 66 const match = diffStat.match( 67 /(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/ 68 ); 69 if (match) { 70 filesChanged += parseInt(match[1]) || 0; 71 linesAdded += parseInt(match[2]) || 0; 72 linesRemoved += parseInt(match[3]) || 0; 73 } 74 } catch (_error) { 75 // no commits in window — normal 76 } 77 } catch (error) { 78 logger.warn(`Could not fetch git activity for ${repo.name}:`, error.message); 79 } 80 } 81 82 return { 83 commits: allCommits, 84 filesChanged, 85 linesAdded, 86 linesRemoved, 87 }; 88 } 89 90 /** 91 * Get database changes from last 24 hours, including full status/outreach trees 92 */ 93 async function getDatabaseChanges() { 94 try { 95 // Total sites 96 const totalRow = await getOne('SELECT COUNT(*) as total FROM sites'); 97 const total = totalRow?.total ?? 0; 98 99 // New sites (created in last 24h) — use site_status log 100 const newSitesRow = await getOne( 101 `SELECT COUNT(DISTINCT site_id) as newSites FROM site_status 102 WHERE status = 'found' AND created_at > NOW() - INTERVAL '1 day'` 103 ); 104 const newSites = newSitesRow?.newSites ?? 0; 105 106 // Lightweight status tree — uses indexed queries only (no full-table scans) 107 const statusTotals = await getAll('SELECT status, COUNT(*) AS total FROM sites GROUP BY status'); 108 // Use site_status transition log for deltas 109 const statusD24 = await getAll( 110 `SELECT status, COUNT(*) AS delta FROM site_status 111 WHERE created_at > NOW() - INTERVAL '24 hours' GROUP BY status` 112 ); 113 const statusD1 = await getAll( 114 `SELECT status, COUNT(*) AS delta FROM site_status 115 WHERE created_at > NOW() - INTERVAL '1 hour' GROUP BY status` 116 ); 117 const d24map = Object.fromEntries(statusD24.map(r => [r.status, r.delta])); 118 const d1map = Object.fromEntries(statusD1.map(r => [r.status, r.delta])); 119 const totalMap = Object.fromEntries(statusTotals.map(r => [r.status, r.total])); 120 // Same order as npm run status (SITE_STATUS_ORDER in error-categories.js) 121 const SITE_STATUS_ORDER = [ 122 'found', 123 'ignored', 124 'assets_captured', 125 'prog_scored', 126 'semantic_scored', 127 'vision_scored', 128 'enriched', 129 'proposals_drafted', 130 'outreach_partial', 131 'outreach_sent', 132 'high_score', 133 'failing', 134 ]; 135 const seenStatuses = new Set(); 136 const statusTree = []; 137 for (const status of SITE_STATUS_ORDER) { 138 if (totalMap[status] !== undefined) { 139 seenStatuses.add(status); 140 statusTree.push({ 141 status, 142 total: totalMap[status], 143 delta_24h: d24map[status] || 0, 144 delta_1h: d1map[status] || 0, 145 children: null, 146 }); 147 } 148 } 149 for (const r of statusTotals) { 150 if (!seenStatuses.has(r.status)) { 151 statusTree.push({ 152 status: r.status, 153 total: r.total, 154 delta_24h: d24map[r.status] || 0, 155 delta_1h: d1map[r.status] || 0, 156 children: null, 157 }); 158 } 159 } 160 161 // Cross-tab: outreach contact_method × status 162 const outreachCrossRows = await getAll( 163 `SELECT contact_method, COALESCE(delivery_status, approval_status) AS status, COUNT(*) AS total 164 FROM messages WHERE direction = 'outbound' 165 GROUP BY contact_method, status` 166 ); 167 const outreachSentTodayRow = await getOne( 168 `SELECT COUNT(*) AS n FROM messages 169 WHERE direction = 'outbound' 170 AND delivery_status IN ('sent','delivered') 171 AND updated_at > NOW() - INTERVAL '24 hours'` 172 ); 173 const outreachSentToday = outreachSentTodayRow?.n || 0; 174 175 // Cross-tab: inbound messages intent × sentiment 176 const convCrossRows = await getAll( 177 `SELECT COALESCE(sentiment,'unknown') AS sentiment, 178 COALESCE(intent,'unknown') AS intent, 179 COUNT(*) AS total 180 FROM messages 181 WHERE direction = 'inbound' 182 GROUP BY sentiment, intent` 183 ); 184 const convChannelRows = await getAll( 185 `SELECT contact_method AS channel, COUNT(*) AS total, 186 0 AS replied 187 FROM messages WHERE direction='inbound' 188 GROUP BY contact_method ORDER BY total DESC` 189 ); 190 191 // Compute actable counts (same logic as getActionableQueues in status.js) 192 const actableMap = {}; 193 try { 194 const blockedCountries = (process.env.OUTREACH_BLOCKED_COUNTRIES || '') 195 .split(',') 196 .map(s => s.trim().toUpperCase()) 197 .filter(Boolean); 198 const skipMethods = (process.env.OUTREACH_SKIP_METHODS || '') 199 .split(',') 200 .map(s => s.trim().toLowerCase()) 201 .filter(Boolean); 202 const englishOnlyMarkets = (process.env.ENGLISH_ONLY_MARKETS || '') 203 .split(',') 204 .map(s => s.trim().toUpperCase()) 205 .filter(Boolean); 206 const lowScoreCutoff = parseFloat(process.env.LOW_SCORE_CUTOFF || '82'); 207 const eligibleChannels = ['email', 'sms'].filter(ch => !skipMethods.includes(ch)); 208 const blockedSet = new Set(blockedCountries); 209 const templatesDir = join(projectRoot, 'data', 'templates'); 210 const templateCountries = new Set( 211 existsSync(templatesDir) 212 ? readdirSync(templatesDir) 213 .filter(cc => existsSync(join(templatesDir, cc, 'email.json'))) 214 .map(cc => cc.toUpperCase()) 215 : [] 216 ); 217 const eligibleCodes = [...templateCountries].filter(cc => !blockedSet.has(cc)); 218 const englishActiveCodes = englishOnlyMarkets.filter(cc => !blockedSet.has(cc)); 219 220 // scoring stages 221 if (englishActiveCodes.length > 0) { 222 const ccP = englishActiveCodes.map((_, i) => `$${i + 2}`).join(','); 223 for (const status of ['semantic_scored', 'vision_scored', 'prog_scored']) { 224 const row = await getOne( 225 `SELECT COUNT(*) as n FROM sites WHERE status = $1 AND UPPER(country_code) IN (${ccP})`, 226 [status, ...englishActiveCodes] 227 ); 228 actableMap[status] = row?.n || 0; 229 } 230 } 231 // enriched intermediate 232 if (englishActiveCodes.length > 0) { 233 const ccP = englishActiveCodes.map((_, i) => `$${i + 3}`).join(','); 234 for (const status of ['enriched_regex', 'enriched_llm']) { 235 const row = await getOne( 236 `SELECT COUNT(*) as n FROM sites WHERE status = $1 AND (score IS NULL OR score < $2) AND UPPER(country_code) IN (${ccP})`, 237 [status, lowScoreCutoff, ...englishActiveCodes] 238 ); 239 actableMap[status] = row?.n || 0; 240 } 241 } 242 // enriched (gate 3) 243 if (eligibleCodes.length > 0) { 244 const ccP = eligibleCodes.map((_, i) => `$${i + 2}`).join(','); 245 const row = await getOne( 246 `SELECT COUNT(*) as n FROM sites WHERE status = 'enriched' AND (score IS NULL OR score < $1) AND UPPER(country_code) IN (${ccP})`, 247 [lowScoreCutoff, ...eligibleCodes] 248 ); 249 actableMap['enriched'] = row?.n || 0; 250 } 251 // proposals_drafted (gate 2) 252 if (eligibleChannels.length > 0 && eligibleCodes.length > 0) { 253 const chP = eligibleChannels.map((_, i) => `$${i + 1}`).join(','); 254 const ccP = eligibleCodes.map((_, i) => `$${eligibleChannels.length + i + 1}`).join(','); 255 const row = await getOne( 256 `SELECT COUNT(DISTINCT m.site_id) as n FROM messages m JOIN sites s ON m.site_id=s.id 257 WHERE s.status = 'proposals_drafted' 258 AND m.direction='outbound' AND m.message_type='outreach' 259 AND m.reworded_at IS NULL AND m.sent_at IS NULL 260 AND m.delivery_status IS NULL 261 AND m.contact_method IN (${chP}) 262 AND UPPER(s.country_code) IN (${ccP})`, 263 [...eligibleChannels, ...eligibleCodes] 264 ); 265 actableMap['proposals_drafted'] = row?.n || 0; 266 } 267 } catch (_e) { 268 logger.warn('Could not compute actable counts:', _e.message); 269 } 270 271 // Attach actable to statusTree rows 272 for (const row of statusTree) { 273 row.actable = actableMap[row.status] ?? null; 274 } 275 276 // Quick lookup for summary generation 277 const statusCounts = statusTree.reduce((acc, row) => { 278 acc[row.status] = row.total; 279 return acc; 280 }, {}); 281 282 return { 283 totalSites: total, 284 newSites, 285 statusTree, 286 outreachCrossRows, 287 outreachSentToday, 288 convCrossRows, 289 convChannelRows, 290 // Fields used by generateSummary() 291 assetsCaptured: statusCounts.assets_captured || 0, 292 failing: statusCounts.failing || 0, 293 found: statusCounts.found || 0, 294 ignore: statusCounts.ignore || 0, 295 }; 296 } catch (error) { 297 logger.warn('Could not fetch database changes:', error.message); 298 return null; 299 } 300 } 301 302 /** 303 * Get code quality metrics 304 */ 305 async function getCodeQualityMetrics() { 306 try { 307 // Check for new TODOs 308 let newTodos = 0; 309 310 try { 311 // Count commits mentioning TODO/FIXME (fast, no full diff needed) 312 const { stdout: todosRaw } = await execAsync( 313 'git log --since="24 hours ago" --oneline --grep="TODO\\|FIXME" | wc -l', 314 { cwd: projectRoot, shell: '/bin/bash', timeout: 10000 } 315 ); 316 newTodos = parseInt(todosRaw.trim()) || 0; 317 } catch (_error) { 318 newTodos = 0; 319 } 320 321 // Read most recent test results (if available) 322 // For now, return placeholder values 323 return { 324 coverage: 82, // Last known coverage 325 coverageChange: undefined, // Would need historical tracking 326 failingTests: 0, 327 newTodos, 328 }; 329 } catch (_error) { 330 logger.warn('Could not fetch code quality metrics:', _error.message); 331 return { 332 coverage: null, 333 coverageChange: null, 334 failingTests: null, 335 newTodos: 0, 336 }; 337 } 338 } 339 340 /** 341 * Get system health (recent errors from logs) 342 */ 343 function getSystemHealth() { 344 try { 345 const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD 346 const logFiles = [ 347 `logs/pipeline-${today}.log`, 348 `logs/outreach-${today}.log`, 349 `logs/inbound-${today}.log`, 350 ]; 351 352 const errors = []; 353 354 for (const logFile of logFiles) { 355 const logPath = join(projectRoot, logFile); 356 357 // eslint-disable-next-line security/detect-non-literal-fs-filename -- Dynamic log file path 358 if (existsSync(logPath)) { 359 try { 360 const content = readFileSync(logPath, 'utf-8'); 361 const lines = content.split('\n'); 362 363 // Extract error lines (simple grep-like approach) 364 const errorLines = lines 365 .filter(line => line.includes('[ERROR]') && !line.includes('test')) // Exclude test errors 366 .slice(-10); // Last 10 errors 367 368 errorLines.forEach(line => { 369 // Extract error message (simplified) 370 const match = line.match(/\[ERROR\]\s+(.+?)(?:\n|$)/); 371 if (match) { 372 errors.push({ message: match[1].substring(0, 100) }); // Truncate long messages 373 } 374 }); 375 } catch (error) { 376 logger.warn(`Could not read log file ${logFile}`); 377 } 378 } 379 } 380 381 // Deduplicate similar errors 382 const uniqueErrors = []; 383 const seen = new Set(); 384 385 errors.forEach(error => { 386 const key = error.message.substring(0, 50); // Use first 50 chars as key 387 if (!seen.has(key)) { 388 seen.add(key); 389 uniqueErrors.push(error); 390 } 391 }); 392 393 return { 394 errors: uniqueErrors, 395 }; 396 } catch (_error) { 397 logger.warn('Could not fetch system health:', _error.message); 398 return { 399 errors: [], 400 }; 401 } 402 } 403 404 /** 405 * Generate executive summary from collected data 406 */ 407 function generateSummary(gitActivity, dbChanges, codeQuality, systemHealth) { 408 const shipped = []; 409 const nextSteps = []; 410 411 // What shipped 412 if (gitActivity.commits.length > 0) { 413 const commitCount = gitActivity.commits.length; 414 415 // Summarize commits by type 416 const features = gitActivity.commits.filter(c => c.message.startsWith('feat')).length; 417 const fixes = gitActivity.commits.filter(c => c.message.startsWith('fix')).length; 418 const tests = gitActivity.commits.filter(c => c.message.includes('test')).length; 419 420 if (features > 0) { 421 shipped.push(`${features} new feature${features > 1 ? 's' : ''} implemented`); 422 } 423 424 if (fixes > 0) { 425 shipped.push(`${fixes} bug fix${fixes > 1 ? 'es' : ''} deployed`); 426 } 427 428 if (tests > 0) { 429 shipped.push(`${tests} test improvement${tests > 1 ? 's' : ''} added`); 430 } 431 432 if (codeQuality?.coverageChange > 0) { 433 shipped.push(`Test coverage increased by ${codeQuality.coverageChange}%`); 434 } 435 436 if (gitActivity.filesChanged > 0) { 437 shipped.push( 438 `${gitActivity.filesChanged} files modified (+${gitActivity.linesAdded.toLocaleString()}, -${gitActivity.linesRemoved.toLocaleString()} lines)` 439 ); 440 } 441 442 if (shipped.length === 0 && commitCount > 0) { 443 shipped.push(`${commitCount} commit${commitCount > 1 ? 's' : ''} merged`); 444 } 445 } 446 447 if (dbChanges?.newSites > 0) { 448 shipped.push( 449 `Pipeline processed ${dbChanges.newSites.toLocaleString()} new sites (${dbChanges.assetsCaptured} captured)` 450 ); 451 } 452 453 if (shipped.length === 0) { 454 shipped.push('Maintenance and monitoring (no major changes)'); 455 } 456 457 // Next steps 458 if (systemHealth?.errors?.length > 0) { 459 nextSteps.push( 460 `Investigate ${systemHealth.errors.length} recent error${systemHealth.errors.length > 1 ? 's' : ''} in logs` 461 ); 462 } 463 464 if (codeQuality?.coverage < 80) { 465 nextSteps.push(`Improve test coverage to 80%+ target (currently ${codeQuality.coverage}%)`); 466 } 467 468 if (dbChanges?.failing > 0) { 469 nextSteps.push(`Review ${dbChanges.failing} failing sites for retry/debugging`); 470 } 471 472 if (nextSteps.length === 0) { 473 nextSteps.push('Continue with planned roadmap items'); 474 nextSteps.push('Monitor system performance and error rates'); 475 } 476 477 return { 478 shipped, 479 nextSteps, 480 }; 481 } 482 483 /** 484 * Main execution 485 */ 486 async function main() { 487 try { 488 logger.info('Collecting data for daily progress report...'); 489 490 // Gather all data 491 const gitActivity = await getGitActivity(); 492 const dbChanges = await getDatabaseChanges(); 493 const codeQuality = await getCodeQualityMetrics(); 494 const systemHealth = getSystemHealth(); 495 496 // Generate summary 497 const summary = generateSummary(gitActivity, dbChanges, codeQuality, systemHealth); 498 499 logger.info('Generating HTML report...'); 500 501 // Generate HTML report 502 const filepath = await generateDailyReport({ 503 summary, 504 gitActivity, 505 dbChanges, 506 codeQuality, 507 systemHealth, 508 }); 509 510 console.log('\nDaily Progress Report Generated\n'); 511 console.log(`Location: ${filepath}\n`); 512 console.log('Summary:'); 513 console.log(` ${gitActivity.commits.length} commits in last 24 hours`); 514 console.log(` ${dbChanges?.newSites || 0} new sites processed`); 515 console.log(` ${systemHealth.errors.length} errors logged\n`); 516 console.log(`Open in browser: file://${filepath}\n`); 517 518 process.exit(0); 519 } catch (error) { 520 logger.error('Failed to generate daily report:', error); 521 process.exit(1); 522 } finally { 523 await closePool(); 524 } 525 } 526 527 main();