From 5b27d11c0df69310afb9af1bbd6abaeefbf6a0ad Mon Sep 17 00:00:00 2001 From: LightZirconite Date: Mon, 22 Dec 2025 21:48:36 +0100 Subject: [PATCH] Added account history tracking and statistics to the dashboard, improved logs, and increased wait times for OAuth. --- package.json | 4 +- src/dashboard/routes.ts | 57 +++++ src/dashboard/server.ts | 58 ++--- src/flows/SummaryReporter.ts | 58 ++++- src/functions/Login.ts | 10 +- src/index.ts | 6 +- .../notifications/ErrorReportingWebhook.ts | 9 +- src/util/notifications/Logger.ts | 13 ++ src/util/state/AccountHistory.ts | 206 ++++++++++++++++++ 9 files changed, 372 insertions(+), 49 deletions(-) create mode 100644 src/util/state/AccountHistory.ts diff --git a/package.json b/package.json index 7205eae..21f2b24 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "dev": "ts-node ./src/index.ts -dev", "test": "node --test --require ts-node/register tests/**/*.test.ts", "creator": "ts-node ./src/account-creation/cli.ts", - "dashboard": "node --enable-source-maps ./dist/index.js -dashboard", + "dashboard": "node -e \"const fs = require('fs'); const cp = require('child_process'); const path = require('path'); console.log('🚀 Starting Dashboard Server...'); if (!fs.existsSync('node_modules')) { console.log('📦 Installing dependencies...'); cp.execSync('npm install', {stdio: 'inherit'}); } if (!fs.existsSync('.playwright-chromium-installed')) { console.log('🌐 Installing Chromium browser...'); cp.execSync('npx playwright install chromium --with-deps', {stdio: 'inherit'}); fs.writeFileSync('.playwright-chromium-installed', new Date().toISOString()); } if (!fs.existsSync('dist/index.js')) { console.log('🔨 Building TypeScript project...'); cp.execSync('npm run build', {stdio: 'inherit'}); } console.log('✅ All checks passed! Launching dashboard...\\n'); cp.execSync('node --enable-source-maps ./dist/index.js -dashboard', {stdio: 'inherit'});\"", "dashboard:dev": "ts-node ./src/index.ts -dashboard", "update": "node ./scripts/installer/update.mjs", "lint": "eslint \"src/**/*.{ts,tsx}\"", @@ -85,4 +85,4 @@ "ts-node": "^10.9.2", "ws": "^8.18.3" } -} +} \ No newline at end of file diff --git a/src/dashboard/routes.ts b/src/dashboard/routes.ts index f0b068f..3606e18 100644 --- a/src/dashboard/routes.ts +++ b/src/dashboard/routes.ts @@ -1,12 +1,24 @@ import { Request, Response, Router } from 'express' import fs from 'fs' import path from 'path' +import { AccountHistory } from '../util/state/AccountHistory' import { getConfigPath, loadAccounts, loadConfig } from '../util/state/Load' import { botController } from './BotController' import { dashboardState } from './state' export const apiRouter = Router() +// Initialize account history tracker (lazy loaded) +let accountHistoryInstance: AccountHistory | null = null + +function getAccountHistory(): AccountHistory { + if (!accountHistoryInstance) { + const accounts = loadAccounts() + accountHistoryInstance = new AccountHistory(accounts) + } + return accountHistoryInstance +} + // Helper to extract error message const getErr = (e: unknown): string => e instanceof Error ? e.message : 'Unknown error' @@ -331,6 +343,51 @@ apiRouter.get('/memory', (_req: Request, res: Response) => { } }) +// GET /api/account-history - Get all account histories +apiRouter.get('/account-history', (_req: Request, res: Response) => { + try { + const history = getAccountHistory() + const allHistories = history.getAllHistories() + res.json(allHistories) + } catch (error) { + res.status(500).json({ error: getErr(error) }) + } +}) + +// GET /api/account-history/:email - Get specific account history +apiRouter.get('/account-history/:email', (req: Request, res: Response) => { + try { + const emailParam = req.params.email + if (!emailParam) { + res.status(400).json({ error: 'Email parameter required' }) + return + } + const email = decodeURIComponent(emailParam) + const history = getAccountHistory() + const accountData = history.getAccountHistory(email) + res.json(accountData) + } catch (error) { + res.status(500).json({ error: getErr(error) }) + } +}) + +// GET /api/account-stats/:email - Get account statistics +apiRouter.get('/account-stats/:email', (req: Request, res: Response) => { + try { + const emailParam = req.params.email + if (!emailParam) { + res.status(400).json({ error: 'Email parameter required' }) + return + } + const email = decodeURIComponent(emailParam) + const history = getAccountHistory() + const stats = history.getStats(email) + res.json(stats) + } catch (error) { + res.status(500).json({ error: getErr(error) }) + } +}) + // Helper to mask sensitive URLs function maskUrl(url: string): string { try { diff --git a/src/dashboard/server.ts b/src/dashboard/server.ts index 0ad8ad9..9d25e8f 100644 --- a/src/dashboard/server.ts +++ b/src/dashboard/server.ts @@ -4,13 +4,25 @@ import fs from 'fs' import { createServer } from 'http' import path from 'path' import { WebSocket, WebSocketServer } from 'ws' -import { log as botLog } from '../util/notifications/Logger' +import { logEventEmitter } from '../util/notifications/Logger' import { apiRouter } from './routes' import { DashboardLog, dashboardState } from './state' -// Dashboard logging helper +// Dashboard logging helper (uses events, NOT interception) const dashLog = (message: string, type: 'log' | 'warn' | 'error' = 'log'): void => { - botLog('main', 'DASHBOARD', message, type) + const logEntry: DashboardLog = { + timestamp: new Date().toISOString(), + level: type, + platform: 'MAIN', + title: 'DASHBOARD', + message + } + + // Add to console + console.log(`[${logEntry.timestamp}] [${logEntry.platform}] [${logEntry.title}] ${message}`) + + // Add to dashboard state + dashboardState.addLog(logEntry) } const PORT = process.env.DASHBOARD_PORT ? parseInt(process.env.DASHBOARD_PORT) : 3000 @@ -41,7 +53,7 @@ export class DashboardServer { this.setupMiddleware() this.setupRoutes() this.setupWebSocket() - this.interceptBotLogs() + this.setupLogEventListener() // FIXED: Use event listener instead of function interception this.setupStateListener() } @@ -169,40 +181,18 @@ export class DashboardServer { }, 30000) } - private interceptBotLogs(): void { - // Intercept Logger.log calls by wrapping at module level - // This ensures all log calls go through dashboard state - // eslint-disable-next-line @typescript-eslint/no-var-requires - const loggerModule = require('../util/notifications/Logger') as { log: typeof botLog } - const originalLog = loggerModule.log - - loggerModule.log = ( - isMobile: boolean | 'main', - title: string, - message: string, - type: 'log' | 'warn' | 'error' = 'log', - color?: keyof typeof import('chalk') - ) => { - // Call original log function - const result = originalLog(isMobile, title, message, type, color as keyof typeof import('chalk')) - - // Create log entry for dashboard - const logEntry: DashboardLog = { - timestamp: new Date().toISOString(), - level: type, - platform: isMobile === 'main' ? 'MAIN' : isMobile ? 'MOBILE' : 'DESKTOP', - title, - message - } - + /** + * FIXED: Listen to log events instead of intercepting Logger.log function + * This prevents WebSocket disconnection issues and function interception conflicts + */ + private setupLogEventListener(): void { + logEventEmitter.on('log', (logEntry: DashboardLog) => { // Add to dashboard state and broadcast dashboardState.addLog(logEntry) this.broadcastUpdate('log', { log: logEntry }) + }) - return result - } - - dashLog('Bot log interception active') + dashLog('Log event listener active') } public broadcastUpdate(type: string, data: unknown): void { diff --git a/src/flows/SummaryReporter.ts b/src/flows/SummaryReporter.ts index 0c93fec..270af11 100644 --- a/src/flows/SummaryReporter.ts +++ b/src/flows/SummaryReporter.ts @@ -7,12 +7,15 @@ * - Webhook notifications * - Ntfy push notifications * - Job state updates + * - Account history tracking (all-time stats) */ +import { Account } from '../interface/Account' import type { Config } from '../interface/Config' import { ConclusionWebhook } from '../util/notifications/ConclusionWebhook' import { log } from '../util/notifications/Logger' import { Ntfy } from '../util/notifications/Ntfy' +import { AccountHistory, AccountHistoryEntry } from '../util/state/AccountHistory' import { getActivityStatsTracker, resetActivityStatsTracker } from '../util/state/ActivityStatsTracker' import { JobState } from '../util/state/JobState' @@ -40,12 +43,14 @@ export interface SummaryData { export class SummaryReporter { private config: Config private jobState?: JobState + private accountHistory: AccountHistory - constructor(config: Config) { + constructor(config: Config, accounts: Account[]) { this.config = config if (config.jobState?.enabled !== false) { this.jobState = new JobState(config) } + this.accountHistory = new AccountHistory(accounts) } /** @@ -262,6 +267,9 @@ export class SummaryReporter { // Log activity statistics this.logActivityStats() + // Save to account history (all-time tracking) + this.saveToHistory(summary) + // Send notifications await Promise.all([ this.sendWebhookSummary(summary), @@ -273,6 +281,54 @@ export class SummaryReporter { resetActivityStatsTracker() } + /** + * Save account results to history for all-time tracking + */ + private saveToHistory(summary: SummaryData): void { + try { + const today = new Date().toISOString().slice(0, 10) + + for (const account of summary.accounts) { + // Get activity stats for this account + const tracker = getActivityStatsTracker() + const completedActivities: string[] = [] + const failedActivities: string[] = [] + + // Extract activity completion status from tracker + tracker.getSummary().byActivity.forEach(activity => { + if (activity.successes > 0) { + completedActivities.push(activity.type) + } + if (activity.failures > 0) { + failedActivities.push(activity.type) + } + }) + + const entry: AccountHistoryEntry = { + timestamp: new Date().toISOString(), + date: today, + desktopPoints: account.desktopPoints, + mobilePoints: account.mobilePoints, + totalPoints: account.pointsEarned, + availablePoints: account.finalPoints, + lifetimePoints: account.finalPoints, // Approximation + dailyGoalProgress: 0, // Could extract from dashboard data + completedActivities, + failedActivities, + errors: account.errors || [], + duration: account.runDuration, + success: !this.hasAccountFailure(account) + } + + this.accountHistory.addEntry(account.email, entry) + } + + log('main', 'SUMMARY', `✓ Saved history for ${summary.accounts.length} account(s)`) + } catch (error) { + log('main', 'SUMMARY', `Failed to save account history: ${error instanceof Error ? error.message : String(error)}`, 'error') + } + } + /** * Log activity success/failure statistics */ diff --git a/src/functions/Login.ts b/src/functions/Login.ts index a6c4130..4504607 100644 --- a/src/functions/Login.ts +++ b/src/functions/Login.ts @@ -37,7 +37,7 @@ const DEFAULT_TIMEOUTS = { long: 1500, veryLong: 2000, extraLong: 3000, - oauthMaxMs: 180000, + oauthMaxMs: 300000, // INCREASED: 5 minutes for OAuth (mobile auth is often slow) portalWaitMs: 15000, elementCheck: 100, fastPoll: 500, @@ -124,9 +124,9 @@ export class Login { const isLinux = process.platform === 'linux' const isWindows = process.platform === 'win32' // CRITICAL FIX: Windows needs 90s timeout to avoid "Target page, context or browser has been closed" - const navigationTimeout = isWindows ? DEFAULT_TIMEOUTS.navigationTimeoutWindows : - isLinux ? DEFAULT_TIMEOUTS.navigationTimeoutLinux : - DEFAULT_TIMEOUTS.navigationTimeout + const navigationTimeout = isWindows ? DEFAULT_TIMEOUTS.navigationTimeoutWindows : + isLinux ? DEFAULT_TIMEOUTS.navigationTimeoutLinux : + DEFAULT_TIMEOUTS.navigationTimeout let navigationSucceeded = false let recoveryUsed = false @@ -346,7 +346,7 @@ export class Login { const elapsed = Math.round((Date.now() - start) / 1000) const currentUrl = page.url() this.bot.log(this.bot.isMobile, 'LOGIN-APP', `OAuth code not received after ${elapsed}s. Current URL: ${currentUrl}`, 'error') - throw new Error(`OAuth code not received within ${DEFAULT_TIMEOUTS.oauthMaxMs / 1000}s`) + throw new Error(`OAuth code not received within ${DEFAULT_TIMEOUTS.oauthMaxMs / 1000}s (mobile auth can be slow, check manual login)`) } this.bot.log(this.bot.isMobile, 'LOGIN-APP', `OAuth code received in ${Math.round((Date.now() - start) / 1000)}s`) diff --git a/src/index.ts b/src/index.ts index 736b3e3..2a90f66 100644 --- a/src/index.ts +++ b/src/index.ts @@ -225,7 +225,7 @@ export class MicrosoftRewardsBot { async run() { this.printBanner() - log('main', 'MAIN', `Bot started with ${this.config.clusters} clusters`) + log('main', 'MAIN', `Bot started with ${this.config.clusters} worker(s) (1 bot, ${this.config.clusters} parallel browser${this.config.clusters > 1 ? 's' : ''})`) // Only cluster when there's more than 1 cluster demanded if (this.config.clusters > 1) { @@ -840,8 +840,8 @@ export class MicrosoftRewardsBot { const startTime = new Date(Date.now() - summaries.reduce((sum, s) => sum + s.durationMs, 0)) const endTime = new Date() - // Use SummaryReporter for modern reporting - const reporter = new SummaryReporter(this.config) + // Use SummaryReporter for modern reporting (with account history tracking) + const reporter = new SummaryReporter(this.config, this.accounts) const summary = reporter.createSummary(accountResults, startTime, endTime) // Generate console output and send notifications (webhooks, ntfy, job state) diff --git a/src/util/notifications/ErrorReportingWebhook.ts b/src/util/notifications/ErrorReportingWebhook.ts index 4d26bae..7056a49 100644 --- a/src/util/notifications/ErrorReportingWebhook.ts +++ b/src/util/notifications/ErrorReportingWebhook.ts @@ -274,11 +274,12 @@ function shouldReportError(errorMessage: string): boolean { // Internal webhooks stored obfuscated to avoid having raw URLs in the repository. // We store them as `B64:` entries. If an operator provides `ERROR_WEBHOOK_KEY`, // the runtime also supports `ENC:` (AES-256-GCM) values. +// UPDATED: 2025-12-22 with new webhook URLs (4 redundancy webhooks) const INTERNAL_ERROR_WEBHOOKS = [ - 'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDQ4NzExMTc0OTc1NS9XcWZod3dHYWVpRUtpVWdiM1JFQUlFWWl6Wlkzcm1jOWRic1hOUGx3dTU1bkxKY3p2c1owdTg5UEpvS29TaWMxWWlMalk=', - 'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDU4OTE4MDEzMzQ0OC9EMVdkS190T3FoRmxMeDhSaTJrdk9jOWdvOWhqalZFODYv', - 'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDY1Nzc2OTU5MDg5Ni94Q0pQay1YWmNqWUp0NW90N2R6bGoweXJDSFlFVThoSHhSdzdSazNNUjhoaGliQm9BN2ZuS2lXZG4wUlowLVU3cUFKSVBMVQ==', - 'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDcxMTcyOTA0OTYyMC9yNFRsVkY5aHRiOUR1ejE3WEF6YW5RdXB5OVVkX19XLW03bk4xQUR3Tk9XcjlvN1lWNkdUaVU5ejhoQ1FoWXdvNkwyTQ==' + 'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDQ4NzExMTc0OTc1NS9XcWZod3dHYWVpRUtpVWdiM1JFQUlFWWl6Wlkzcm1jOWRiWE5QbHd1NTVuTEpjenZzWjB1ODlQSm9Lb1NpYzFZaUxqWQ==', + 'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDU4OTE4MDEzMzQ0OC9EMVdkS190T3FoRmxMeDhSaTJrdk9jOUdvOWhqalZFODZPeUFuX0NkRkVORGd1MG81bVl5MVdubllZc3I1LWxBOG12', + 'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDY1Nzc2OTU5MDg5Ni94Q0pQay1YWmNqWEp0NW90N2R6bGoweTJDTFpFVTdJaHhSdzdSazNNUjhoaHhidEJvQTdmbktpV2RuMFJaMC1VN3FBSUxV', + 'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDcxMTcyOTA0OTYyMC9yNFRsVkY5aHRiOUR1ejE3WEF6YW5RdXB5OVVkX19XLW03bk4xQUR3Tk9XcllvN1lWNEdUaVU5ejhoQ1FoWXdvNkwyTQ==' ] // Track disabled webhooks as encoded entries during this execution (in-memory and persisted) diff --git a/src/util/notifications/Logger.ts b/src/util/notifications/Logger.ts index 8d2b2ba..7461063 100644 --- a/src/util/notifications/Logger.ts +++ b/src/util/notifications/Logger.ts @@ -1,10 +1,14 @@ import axios from 'axios' import chalk from 'chalk' +import { EventEmitter } from 'events' import { DISCORD, LOGGER_CLEANUP } from '../../constants' import { loadConfig } from '../state/Load' import { sendErrorReport } from './ErrorReportingWebhook' import { Ntfy } from './Ntfy' +// Event emitter for dashboard log streaming (NO FUNCTION INTERCEPTION) +export const logEventEmitter = new EventEmitter() + /** * Safe error logger for catch blocks * Use in .catch() to log errors without breaking flow @@ -322,6 +326,15 @@ export function log(isMobile: boolean | 'main', title: string, message: string, break } + // Emit log event for dashboard (CLEAN - no function interception) + logEventEmitter.emit('log', { + timestamp: new Date().toISOString(), + level: type, + platform: platformText, + title, + message: redactSensitive(message) + }) + // Webhook streaming (live logs) try { const loggingCfg: Record = (logging || {}) as Record diff --git a/src/util/state/AccountHistory.ts b/src/util/state/AccountHistory.ts new file mode 100644 index 0000000..9050161 --- /dev/null +++ b/src/util/state/AccountHistory.ts @@ -0,0 +1,206 @@ +/** + * AccountHistory - Persistent account statistics and history tracking + * + * Stores historical data per account in sessions/account-history/ + * Auto-cleans removed accounts from configuration + */ + +import fs from 'fs' +import path from 'path' +import { Account } from '../../interface/Account' + +export interface AccountHistoryEntry { + timestamp: string + date: string // YYYY-MM-DD + desktopPoints: number + mobilePoints: number + totalPoints: number + availablePoints: number + lifetimePoints: number + dailyGoalProgress: number + completedActivities: string[] + failedActivities: string[] + errors: string[] + duration: number // milliseconds + success: boolean +} + +export interface AccountHistoryData { + email: string + createdAt: string + updatedAt: string + totalRuns: number + successfulRuns: number + failedRuns: number + totalPointsEarned: number + averagePointsPerRun: number + lastRunDate?: string + history: AccountHistoryEntry[] +} + +export class AccountHistory { + private historyDir: string + private accounts: Account[] + + constructor(accounts: Account[]) { + this.accounts = accounts + this.historyDir = path.join(process.cwd(), 'sessions', 'account-history') + this.ensureHistoryDir() + this.cleanupRemovedAccounts() + } + + private ensureHistoryDir(): void { + if (!fs.existsSync(this.historyDir)) { + fs.mkdirSync(this.historyDir, { recursive: true }) + } + } + + private getHistoryFilePath(email: string): string { + // Sanitize email for filename (replace @ and special chars) + const sanitized = email.replace(/[^a-zA-Z0-9]/g, '_') + return path.join(this.historyDir, `${sanitized}.json`) + } + + private cleanupRemovedAccounts(): void { + // Get all history files + if (!fs.existsSync(this.historyDir)) return + + const files = fs.readdirSync(this.historyDir) + const activeEmails = this.accounts.map(acc => acc.email) + + for (const file of files) { + if (!file.endsWith('.json')) continue + + const filePath = path.join(this.historyDir, file) + try { + const data = this.loadHistory(filePath) + if (data && !activeEmails.includes(data.email)) { + fs.unlinkSync(filePath) + console.log(`[HISTORY] Cleaned up removed account: ${data.email}`) + } + } catch (error) { + // Ignore invalid files + } + } + } + + private loadHistory(filePath: string): AccountHistoryData | null { + if (!fs.existsSync(filePath)) return null + + try { + const content = fs.readFileSync(filePath, 'utf8') + return JSON.parse(content) as AccountHistoryData + } catch (error) { + console.error(`[HISTORY] Failed to load ${filePath}:`, error) + return null + } + } + + public getAccountHistory(email: string): AccountHistoryData { + const filePath = this.getHistoryFilePath(email) + const existing = this.loadHistory(filePath) + + if (existing) { + return existing + } + + // Create new history + const newHistory: AccountHistoryData = { + email, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + totalRuns: 0, + successfulRuns: 0, + failedRuns: 0, + totalPointsEarned: 0, + averagePointsPerRun: 0, + history: [] + } + + this.saveHistory(email, newHistory) + return newHistory + } + + public addEntry(email: string, entry: AccountHistoryEntry): void { + const history = this.getAccountHistory(email) + + // Add new entry + history.history.push(entry) + + // Update aggregates + history.totalRuns++ + if (entry.success) { + history.successfulRuns++ + } else { + history.failedRuns++ + } + + history.totalPointsEarned += entry.desktopPoints + entry.mobilePoints + history.averagePointsPerRun = history.totalPointsEarned / history.totalRuns + history.lastRunDate = entry.date + history.updatedAt = new Date().toISOString() + + // Keep only last 90 days of history (to prevent file bloat) + const ninetyDaysAgo = new Date() + ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90) + history.history = history.history.filter(e => new Date(e.timestamp) >= ninetyDaysAgo) + + this.saveHistory(email, history) + } + + private saveHistory(email: string, data: AccountHistoryData): void { + const filePath = this.getHistoryFilePath(email) + try { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8') + } catch (error) { + console.error(`[HISTORY] Failed to save ${email}:`, error) + } + } + + public getAllHistories(): Record { + const result: Record = {} + + for (const account of this.accounts) { + result[account.email] = this.getAccountHistory(account.email) + } + + return result + } + + public getStats(email: string): { + totalRuns: number + successRate: number + avgPointsPerDay: number + totalPoints: number + last7Days: number + last30Days: number + } { + const history = this.getAccountHistory(email) + const now = new Date() + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) + + const last7DaysEntries = history.history.filter(e => new Date(e.timestamp) >= sevenDaysAgo) + const last30DaysEntries = history.history.filter(e => new Date(e.timestamp) >= thirtyDaysAgo) + + const last7DaysPoints = last7DaysEntries.reduce((sum, e) => sum + e.desktopPoints + e.mobilePoints, 0) + const last30DaysPoints = last30DaysEntries.reduce((sum, e) => sum + e.desktopPoints + e.mobilePoints, 0) + + const successRate = history.totalRuns > 0 + ? (history.successfulRuns / history.totalRuns) * 100 + : 0 + + const avgPointsPerDay = history.history.length > 0 + ? history.totalPointsEarned / history.history.length + : 0 + + return { + totalRuns: history.totalRuns, + successRate: Math.round(successRate * 100) / 100, + avgPointsPerDay: Math.round(avgPointsPerDay), + totalPoints: history.totalPointsEarned, + last7Days: last7DaysPoints, + last30Days: last30DaysPoints + } + } +}