diff --git a/.dockerignore b/.dockerignore index 0acd217..b0cd200 100644 --- a/.dockerignore +++ b/.dockerignore @@ -42,13 +42,13 @@ ehthumbs.db Thumbs.db -# Test files -test/ -spec/ -**/*.test.js -**/*.spec.js -**/jest.config.* -**/vitest.config.* +# Test files (removed to allow tests in Docker build) +# test/ +# spec/ +# **/*.test.js +# **/*.spec.js +# **/jest.config.* +# **/vitest.config.* # Documentation and assets README.md diff --git a/Dockerfile b/Dockerfile index a3fb0cf..6e8ef63 100644 --- a/Dockerfile +++ b/Dockerfile @@ -73,17 +73,28 @@ RUN apk add --no-cache --virtual .test-deps \ # Copy backend package files and install all dependencies (including dev) COPY backend/package*.json ./backend/ -RUN cd backend && npm install --no-audit --no-fund +RUN cd backend && \ + # Retry npm install with exponential backoff for network issues + for i in 1 2 3; do \ + npm install --no-audit --no-fund && break || \ + (echo "npm install failed, attempt $i/3, retrying in $((i*5)) seconds..." && sleep $((i*5))); \ + done -# Copy backend source code +# Copy backend source code including tests COPY backend/ ./backend/ # Run tests RUN cd backend && npm test +# Create test completion marker to ensure tests passed +RUN echo "Tests passed successfully" > /app/test-success.marker + # Stage 4: Final Production Image (minimal base) FROM node:20-alpine AS production +# Copy test success marker to ensure tests passed before production build +COPY --from=test /app/test-success.marker /tmp/test-success.marker + # Create non-root user first (before installing packages) RUN addgroup -g 1001 -S app && \ adduser -S app -u 1001 -G app diff --git a/backend/models/task.js b/backend/models/task.js index 805da80..b8ce5c8 100644 --- a/backend/models/task.js +++ b/backend/models/task.js @@ -112,16 +112,38 @@ module.exports = (sequelize) => { WAITING: 4 }; - // Instance methods for priority and status - Task.prototype.getPriorityName = function() { + // priority and status + const getPriorityName = (priorityValue) => { const priorities = ['low', 'medium', 'high']; - return priorities[this.priority] || 'low'; + return priorities[priorityValue] || 'low'; }; - Task.prototype.getStatusName = function() { + const getStatusName = (statusValue) => { const statuses = ['not_started', 'in_progress', 'done', 'archived', 'waiting']; - return statuses[this.status] || 'not_started'; + return statuses[statusValue] || 'not_started'; }; + const getPriorityValue = (priorityName) => { + const priorities = { 'low': 0, 'medium': 1, 'high': 2 }; + return priorities[priorityName] !== undefined ? priorities[priorityName] : 0; + }; + + const getStatusValue = (statusName) => { + const statuses = { + 'not_started': 0, + 'in_progress': 1, + 'done': 2, + 'archived': 3, + 'waiting': 4 + }; + return statuses[statusName] !== undefined ? statuses[statusName] : 0; + }; + + // Attach utility functions to model + Task.getPriorityName = getPriorityName; + Task.getStatusName = getStatusName; + Task.getPriorityValue = getPriorityValue; + Task.getStatusValue = getStatusValue; + return Task; }; \ No newline at end of file diff --git a/backend/models/user.js b/backend/models/user.js index 45c5bd7..489eeeb 100644 --- a/backend/models/user.js +++ b/backend/models/user.js @@ -87,14 +87,18 @@ module.exports = (sequelize) => { } }); - // Virtual field for password - User.prototype.setPassword = async function(password) { - this.password_digest = await bcrypt.hash(password, 10); + // password operations + const hashPassword = async (password) => { + return await bcrypt.hash(password, 10); }; - User.prototype.checkPassword = async function(password) { - return await bcrypt.compare(password, this.password_digest); + const checkPassword = async (password, hashedPassword) => { + return await bcrypt.compare(password, hashedPassword); }; + // Attach utility functions to model + User.hashPassword = hashPassword; + User.checkPassword = checkPassword; + return User; }; \ No newline at end of file diff --git a/backend/services/quotesService.js b/backend/services/quotesService.js index 0b1bdf2..2a54e2e 100644 --- a/backend/services/quotesService.js +++ b/backend/services/quotesService.js @@ -2,64 +2,130 @@ const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); -class QuotesService { - constructor() { - this.quotes = []; - this.loadQuotes(); - } +// create default quotes +const createDefaultQuotes = () => [ + "Believe you can and you're halfway there.", + "The only way to do great work is to love what you do.", + "It always seems impossible until it's done.", + "Focus on progress, not perfection.", + "One task at a time leads to great accomplishments." +]; - loadQuotes() { - try { - const quotesPath = path.join(__dirname, '../config/quotes.yml'); - - if (fs.existsSync(quotesPath)) { - const fileContents = fs.readFileSync(quotesPath, 'utf8'); - const data = yaml.load(fileContents); - - if (data && data.quotes && Array.isArray(data.quotes)) { - this.quotes = data.quotes; - console.log(`Loaded ${this.quotes.length} quotes from configuration`); - } else { - console.warn('No quotes found in configuration file'); - this.setDefaultQuotes(); - } - } else { - console.warn('Quotes configuration file not found, using defaults'); - this.setDefaultQuotes(); - } - } catch (error) { - console.error('Error loading quotes:', error.message); - this.setDefaultQuotes(); - } - } +// get quotes file path +const getQuotesFilePath = () => + path.join(__dirname, '../config/quotes.yml'); - setDefaultQuotes() { - this.quotes = [ - "Believe you can and you're halfway there.", - "The only way to do great work is to love what you do.", - "It always seems impossible until it's done.", - "Focus on progress, not perfection.", - "One task at a time leads to great accomplishments." - ]; - } +// Side effect function to check if file exists +const fileExists = (filePath) => + fs.existsSync(filePath); - getRandomQuote() { - if (this.quotes.length === 0) { - return "Stay focused and keep going!"; - } +// Side effect function to read file contents +const readFileContents = (filePath) => + fs.readFileSync(filePath, 'utf8'); + +// parse YAML content +const parseYamlContent = (content) => { + try { + return yaml.load(content); + } catch (error) { + throw new Error(`Failed to parse YAML: ${error.message}`); + } +}; + +// validate quotes data structure +const validateQuotesData = (data) => + data && data.quotes && Array.isArray(data.quotes); + +// extract quotes from data +const extractQuotes = (data) => { + if (validateQuotesData(data)) { + return data.quotes; + } + return null; +}; + +// Side effect function to load quotes from file +const loadQuotesFromFile = () => { + try { + const quotesPath = getQuotesFilePath(); - const randomIndex = Math.floor(Math.random() * this.quotes.length); - return this.quotes[randomIndex]; - } + if (!fileExists(quotesPath)) { + console.warn('Quotes configuration file not found, using defaults'); + return createDefaultQuotes(); + } - getAllQuotes() { - return this.quotes; + const fileContents = readFileContents(quotesPath); + const data = parseYamlContent(fileContents); + const quotes = extractQuotes(data); + + if (quotes) { + console.log(`Loaded ${quotes.length} quotes from configuration`); + return quotes; + } else { + console.warn('No quotes found in configuration file'); + return createDefaultQuotes(); + } + } catch (error) { + console.error('Error loading quotes:', error.message); + return createDefaultQuotes(); } +}; - getQuotesCount() { - return this.quotes.length; +// get random index +const getRandomIndex = (arrayLength) => + Math.floor(Math.random() * arrayLength); + +// get random quote from array +const getRandomQuoteFromArray = (quotes) => { + if (quotes.length === 0) { + return "Stay focused and keep going!"; } -} + + const randomIndex = getRandomIndex(quotes.length); + return quotes[randomIndex]; +}; -// Export singleton instance -module.exports = new QuotesService(); \ No newline at end of file +// get all quotes +const getAllQuotesFromArray = (quotes) => + [...quotes]; // Return copy to maintain immutability + +// get quotes count +const getQuotesCount = (quotes) => + quotes.length; + +// Initialize quotes on module load +let quotes = loadQuotesFromFile(); + +// Function to reload quotes (contains side effects) +const reloadQuotes = () => { + quotes = loadQuotesFromFile(); + return quotes; +}; + +// get random quote +const getRandomQuote = () => + getRandomQuoteFromArray(quotes); + +// get all quotes +const getAllQuotes = () => + getAllQuotesFromArray(quotes); + +// get count +const getCount = () => + getQuotesCount(quotes); + +// Export functional interface +module.exports = { + getRandomQuote, + getAllQuotes, + getQuotesCount: getCount, + reloadQuotes, + // For testing + _createDefaultQuotes: createDefaultQuotes, + _getQuotesFilePath: getQuotesFilePath, + _parseYamlContent: parseYamlContent, + _validateQuotesData: validateQuotesData, + _extractQuotes: extractQuotes, + _getRandomIndex: getRandomIndex, + _getRandomQuoteFromArray: getRandomQuoteFromArray +}; \ No newline at end of file diff --git a/backend/services/taskScheduler.js b/backend/services/taskScheduler.js index 7d24281..8cd79c8 100644 --- a/backend/services/taskScheduler.js +++ b/backend/services/taskScheduler.js @@ -2,180 +2,193 @@ const cron = require('node-cron'); const { User } = require('../models'); const TaskSummaryService = require('./taskSummaryService'); -class TaskScheduler { - constructor() { - this.jobs = new Map(); - this.isInitialized = false; - } +// Create scheduler state +const createSchedulerState = () => ({ + jobs: new Map(), + isInitialized: false +}); - static getInstance() { - if (!TaskScheduler.instance) { - TaskScheduler.instance = new TaskScheduler(); +// Global mutable state (will be managed functionally) +let schedulerState = createSchedulerState(); + +// Check if scheduler should be disabled +const shouldDisableScheduler = () => + process.env.NODE_ENV === 'test' || process.env.DISABLE_SCHEDULER === 'true'; + +// Create job configuration +const createJobConfig = () => ({ + scheduled: false, + timezone: 'UTC' +}); + +// Create cron expressions +const getCronExpression = (frequency) => { + const expressions = { + daily: '0 7 * * *', + weekdays: '0 7 * * 1-5', + weekly: '0 7 * * 1', + '1h': '0 * * * *', + '2h': '0 */2 * * *', + '4h': '0 */4 * * *', + '8h': '0 */8 * * *', + '12h': '0 */12 * * *' + }; + return expressions[frequency]; +}; + +// Create job handler +const createJobHandler = (frequency) => async () => { + console.log(`Running scheduled task: ${frequency} task summary`); + await processSummariesForFrequency(frequency); +}; + +// Create job entries +const createJobEntries = () => { + const frequencies = ['daily', 'weekdays', 'weekly', '1h', '2h', '4h', '8h', '12h']; + + return frequencies.map(frequency => { + const cronExpression = getCronExpression(frequency); + const jobHandler = createJobHandler(frequency); + const jobConfig = createJobConfig(); + const job = cron.schedule(cronExpression, jobHandler, jobConfig); + + return [frequency, job]; + }); +}; + +// Start all jobs +const startJobs = (jobs) => { + jobs.forEach((job, frequency) => { + job.start(); + console.log(`Started scheduler for frequency: ${frequency}`); + }); +}; + +// Stop all jobs +const stopJobs = (jobs) => { + jobs.forEach((job, frequency) => { + job.stop(); + console.log(`Stopped scheduler for frequency: ${frequency}`); + }); +}; + +// Side effect function to fetch users for frequency +const fetchUsersForFrequency = async (frequency) => { + return await User.findAll({ + where: { + telegram_bot_token: { [require('sequelize').Op.ne]: null }, + telegram_chat_id: { [require('sequelize').Op.ne]: null }, + task_summary_enabled: true, + task_summary_frequency: frequency } - return TaskScheduler.instance; - } + }); +}; - async initialize() { - if (this.isInitialized) { - console.log('Task scheduler already initialized'); - return; +// Side effect function to send summary to user +const sendSummaryToUser = async (userId, frequency) => { + try { + const success = await TaskSummaryService.sendSummaryToUser(userId); + if (success) { + console.log(`Sent ${frequency} summary to user ${userId}`); + } else { + console.log(`Failed to send ${frequency} summary to user ${userId}`); } + return success; + } catch (error) { + console.error(`Error sending ${frequency} summary to user ${userId}:`, error.message); + return false; + } +}; - // Don't schedule in test environment - if (process.env.NODE_ENV === 'test' || process.env.DISABLE_SCHEDULER === 'true') { - console.log('Task scheduler disabled for test environment'); - return; - } +// Function to process summaries for frequency (contains side effects) +const processSummariesForFrequency = async (frequency) => { + try { + const users = await fetchUsersForFrequency(frequency); + console.log(`Processing ${users.length} users for frequency: ${frequency}`); - console.log('Initializing task scheduler...'); + const results = await Promise.allSettled( + users.map(user => sendSummaryToUser(user.id, frequency)) + ); - // Daily schedule at 7 AM (for users with daily frequency) - const dailyJob = cron.schedule('0 7 * * *', async () => { - console.log('Running scheduled task: Daily task summary'); - await this.processSummariesForFrequency('daily'); - }, { - scheduled: false, - timezone: 'UTC' - }); + return results; + } catch (error) { + console.error(`Error processing summaries for frequency ${frequency}:`, error); + throw error; + } +}; - // Weekdays schedule at 7 AM (Monday through Friday) - const weekdaysJob = cron.schedule('0 7 * * 1-5', async () => { - console.log('Running scheduled task: Weekday task summary'); - await this.processSummariesForFrequency('weekdays'); - }, { - scheduled: false, - timezone: 'UTC' - }); - - // Weekly schedule at 7 AM on Monday - const weeklyJob = cron.schedule('0 7 * * 1', async () => { - console.log('Running scheduled task: Weekly task summary'); - await this.processSummariesForFrequency('weekly'); - }, { - scheduled: false, - timezone: 'UTC' - }); - - // Hourly schedules - const hourlyJob = cron.schedule('0 * * * *', async () => { - console.log('Running scheduled task: Hourly (1h) task summary'); - await this.processSummariesForFrequency('1h'); - }, { - scheduled: false, - timezone: 'UTC' - }); - - const twoHourlyJob = cron.schedule('0 */2 * * *', async () => { - console.log('Running scheduled task: 2-hour task summary'); - await this.processSummariesForFrequency('2h'); - }, { - scheduled: false, - timezone: 'UTC' - }); - - const fourHourlyJob = cron.schedule('0 */4 * * *', async () => { - console.log('Running scheduled task: 4-hour task summary'); - await this.processSummariesForFrequency('4h'); - }, { - scheduled: false, - timezone: 'UTC' - }); - - const eightHourlyJob = cron.schedule('0 */8 * * *', async () => { - console.log('Running scheduled task: 8-hour task summary'); - await this.processSummariesForFrequency('8h'); - }, { - scheduled: false, - timezone: 'UTC' - }); - - const twelveHourlyJob = cron.schedule('0 */12 * * *', async () => { - console.log('Running scheduled task: 12-hour task summary'); - await this.processSummariesForFrequency('12h'); - }, { - scheduled: false, - timezone: 'UTC' - }); - - // Store jobs for later management - this.jobs.set('daily', dailyJob); - this.jobs.set('weekdays', weekdaysJob); - this.jobs.set('weekly', weeklyJob); - this.jobs.set('1h', hourlyJob); - this.jobs.set('2h', twoHourlyJob); - this.jobs.set('4h', fourHourlyJob); - this.jobs.set('8h', eightHourlyJob); - this.jobs.set('12h', twelveHourlyJob); - - // Start all jobs - this.jobs.forEach((job, frequency) => { - job.start(); - console.log(`Started scheduler for frequency: ${frequency}`); - }); - - this.isInitialized = true; - console.log('Task scheduler initialized successfully'); +// Function to initialize scheduler (contains side effects) +const initialize = async () => { + if (schedulerState.isInitialized) { + console.log('Task scheduler already initialized'); + return schedulerState; } - async processSummariesForFrequency(frequency) { - try { - const users = await User.findAll({ - where: { - telegram_bot_token: { [require('sequelize').Op.ne]: null }, - telegram_chat_id: { [require('sequelize').Op.ne]: null }, - task_summary_enabled: true, - task_summary_frequency: frequency - } - }); - - console.log(`Processing ${users.length} users for frequency: ${frequency}`); - - for (const user of users) { - try { - const success = await TaskSummaryService.sendSummaryToUser(user.id); - if (success) { - console.log(`Sent ${frequency} summary to user ${user.id}`); - } else { - console.log(`Failed to send ${frequency} summary to user ${user.id}`); - } - } catch (error) { - console.error(`Error sending ${frequency} summary to user ${user.id}:`, error.message); - } - } - } catch (error) { - console.error(`Error processing summaries for frequency ${frequency}:`, error); - } + if (shouldDisableScheduler()) { + console.log('Task scheduler disabled for test environment'); + return schedulerState; } - async stop() { - if (!this.isInitialized) { - console.log('Task scheduler not initialized, nothing to stop'); - return; - } + console.log('Initializing task scheduler...'); - console.log('Stopping task scheduler...'); - this.jobs.forEach((job, frequency) => { - job.stop(); - console.log(`Stopped scheduler for frequency: ${frequency}`); - }); + // Create job entries + const jobEntries = createJobEntries(); + const jobs = new Map(jobEntries); - this.jobs.clear(); - this.isInitialized = false; - console.log('Task scheduler stopped'); + // Start all jobs + startJobs(jobs); + + // Update state immutably + schedulerState = { + jobs, + isInitialized: true + }; + + console.log('Task scheduler initialized successfully'); + return schedulerState; +}; + +// Function to stop scheduler (contains side effects) +const stop = async () => { + if (!schedulerState.isInitialized) { + console.log('Task scheduler not initialized, nothing to stop'); + return schedulerState; } - async restart() { - await this.stop(); - await this.initialize(); - } + console.log('Stopping task scheduler...'); + + // Stop all jobs + stopJobs(schedulerState.jobs); - getStatus() { - return { - initialized: this.isInitialized, - jobCount: this.jobs.size, - jobs: Array.from(this.jobs.keys()) - }; - } -} + // Reset state immutably + schedulerState = createSchedulerState(); + + console.log('Task scheduler stopped'); + return schedulerState; +}; -module.exports = TaskScheduler; \ No newline at end of file +// Function to restart scheduler +const restart = async () => { + await stop(); + return await initialize(); +}; + +// Get scheduler status +const getStatus = () => ({ + initialized: schedulerState.isInitialized, + jobCount: schedulerState.jobs.size, + jobs: Array.from(schedulerState.jobs.keys()) +}); + +// Export functional interface +module.exports = { + initialize, + stop, + restart, + getStatus, + processSummariesForFrequency, + // For testing + _createSchedulerState: createSchedulerState, + _shouldDisableScheduler: shouldDisableScheduler, + _getCronExpression: getCronExpression +}; \ No newline at end of file diff --git a/backend/services/taskSummaryService.js b/backend/services/taskSummaryService.js index 07e05c5..5499f36 100644 --- a/backend/services/taskSummaryService.js +++ b/backend/services/taskSummaryService.js @@ -2,237 +2,285 @@ const { User, Task, Project, Tag } = require('../models'); const { Op } = require('sequelize'); const TelegramPoller = require('./telegramPoller'); -class TaskSummaryService { - // Helper method to escape special characters for MarkdownV2 - static escapeMarkdown(text) { - if (!text) return ''; - // Characters that need to be escaped in MarkdownV2: _*[]()~`>#+-=|{}.! - return text.toString().replace(/([_*\[\]()~`>#+\-=|{}.!])/g, '\\$1'); - } +// escape markdown special characters +const escapeMarkdown = (text) => { + if (!text) return ''; + // Characters that need to be escaped in MarkdownV2: _*[]()~`>#+-=|{}.! + return text.toString().replace(/([_*\[\]()~`>#+\-=|{}.!])/g, '\\$1'); +}; - static async generateSummaryForUser(userId) { - try { - const user = await User.findByPk(userId); - if (!user) return null; +// get priority emoji +const getPriorityEmoji = (priority) => { + const emojiMap = { + 2: '🔴', // high + 1: '🟠', // medium + 0: '🟢' // low + }; + return emojiMap[priority] || '⚪'; +}; - // Get today's date - const today = new Date(); - today.setHours(0, 0, 0, 0); - const tomorrow = new Date(today); - tomorrow.setDate(tomorrow.getDate() + 1); +// create date range for today +const createTodayDateRange = () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + return { today, tomorrow }; +}; - // Get today's tasks, in progress tasks, etc. - const dueToday = await Task.findAll({ - where: { - user_id: userId, - due_date: { - [Op.gte]: today, - [Op.lt]: tomorrow - }, - status: { [Op.ne]: 2 } // not done - }, - include: [{ model: Project, attributes: ['name'] }], - order: [['name', 'ASC']] - }); +// format task for display +const formatTaskForDisplay = (task, index, includeStatus = false) => { + const priorityEmoji = getPriorityEmoji(task.priority); + const statusEmoji = includeStatus ? '✅ ' : ''; + const taskName = escapeMarkdown(task.name); + const projectInfo = task.Project ? ` \\[${escapeMarkdown(task.Project.name)}\\]` : ''; + return `${index + 1}\\. ${statusEmoji}${priorityEmoji} ${taskName}${projectInfo}\n`; +}; - const inProgress = await Task.findAll({ - where: { - user_id: userId, - status: 1 // in_progress - }, - include: [{ model: Project, attributes: ['name'] }], - order: [['name', 'ASC']] - }); +// build task section +const buildTaskSection = (tasks, title, includeStatus = false) => { + if (tasks.length === 0) return ''; + + let section = `${title}\n`; + section += tasks.map((task, index) => + formatTaskForDisplay(task, index, includeStatus) + ).join(''); + section += '\n'; + + return section; +}; - const completedToday = await Task.findAll({ - where: { - user_id: userId, - status: 2, // done - updated_at: { - [Op.gte]: today, - [Op.lt]: tomorrow - } - }, - include: [{ model: Project, attributes: ['name'] }], - order: [['name', 'ASC']] - }); +// build summary message +const buildSummaryMessage = (taskSections) => { + let message = "📋 *Today's Task Summary*\n\n"; + message += "━━━━━━━━━━━━━━━━━━━━━━━━\n\n"; + message += "✏️ *Today's Plan*\n\n"; + + message += taskSections.dueToday; + message += taskSections.inProgress; + message += taskSections.suggested; + message += taskSections.completed; + + message += "━━━━━━━━━━━━━━━━━━━━━━━━\n"; + message += "🎯 *Stay focused and make it happen\\!*"; + + return message; +}; - // Generate summary message - let message = "📋 *Today's Task Summary*\n\n"; - message += "━━━━━━━━━━━━━━━━━━━━━━━━\n\n"; - message += "✏️ *Today's Plan*\n\n"; +// calculate next run time +const calculateNextRunTime = (user, fromTime = new Date()) => { + const frequency = user.task_summary_frequency; + const from = new Date(fromTime); - // Add due today tasks - if (dueToday.length > 0) { - message += "🚀 *Tasks Due Today:*\n"; - dueToday.forEach((task, index) => { - const priorityEmoji = this.getPriorityEmoji(task.priority); - const taskName = this.escapeMarkdown(task.name); - const projectInfo = task.Project ? ` \\[${this.escapeMarkdown(task.Project.name)}\\]` : ''; - message += `${index + 1}\\. ${priorityEmoji} ${taskName}${projectInfo}\n`; - }); - message += "\n"; + const calculations = { + daily: () => { + const nextDay = new Date(from); + nextDay.setDate(nextDay.getDate() + 1); + nextDay.setHours(7, 0, 0, 0); + return nextDay; + }, + + weekdays: () => { + const currentDay = from.getDay(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday + let daysToAdd = 1; + if (currentDay === 5) { // Friday + daysToAdd = 3; // Skip to Monday + } else if (currentDay === 6) { // Saturday + daysToAdd = 2; // Skip to Monday } - - // Add in progress tasks - if (inProgress.length > 0) { - message += "⚙️ *In Progress Tasks:*\n"; - inProgress.forEach((task, index) => { - const priorityEmoji = this.getPriorityEmoji(task.priority); - const taskName = this.escapeMarkdown(task.name); - const projectInfo = task.Project ? ` \\[${this.escapeMarkdown(task.Project.name)}\\]` : ''; - message += `${index + 1}\\. ${priorityEmoji} ${taskName}${projectInfo}\n`; - }); - message += "\n"; - } - - // Get suggested tasks (not done, not in due today or in progress) - const excludedIds = [...dueToday.map(t => t.id), ...inProgress.map(t => t.id)]; - - const suggestedTasks = await Task.findAll({ - where: { - user_id: userId, - status: { [Op.ne]: 2 }, // not done - id: { [Op.notIn]: excludedIds } - }, - include: [{ model: Project, attributes: ['name'] }], - order: [['priority', 'DESC'], ['name', 'ASC']], - limit: 5 - }); - - if (suggestedTasks.length > 0) { - message += "💡 *Suggested Tasks:*\n"; - suggestedTasks.forEach((task, index) => { - const priorityEmoji = this.getPriorityEmoji(task.priority); - const taskName = this.escapeMarkdown(task.name); - const projectInfo = task.Project ? ` \\[${this.escapeMarkdown(task.Project.name)}\\]` : ''; - message += `${index + 1}\\. ${priorityEmoji} ${taskName}${projectInfo}\n`; - }); - message += "\n"; - } - - // Add completed tasks - if (completedToday.length > 0) { - message += "✅ *Completed Today:*\n"; - completedToday.forEach((task, index) => { - const taskName = this.escapeMarkdown(task.name); - const projectInfo = task.Project ? ` \\[${this.escapeMarkdown(task.Project.name)}\\]` : ''; - message += `${index + 1}\\. ✅ ${taskName}${projectInfo}\n`; - }); - message += "\n"; - } - - // Add footer - message += "━━━━━━━━━━━━━━━━━━━━━━━━\n"; - message += "🎯 *Stay focused and make it happen\\!*"; - - return message; - } catch (error) { - console.error('Error generating task summary:', error); - return null; + const nextWeekday = new Date(from); + nextWeekday.setDate(nextWeekday.getDate() + daysToAdd); + nextWeekday.setHours(7, 0, 0, 0); + return nextWeekday; + }, + + weekly: () => { + const nextWeek = new Date(from); + nextWeek.setDate(nextWeek.getDate() + 7); + nextWeek.setHours(7, 0, 0, 0); + return nextWeek; + }, + + '1h': () => { + const nextHour = new Date(from); + nextHour.setHours(nextHour.getHours() + 1); + return nextHour; + }, + + '2h': () => { + const next = new Date(from); + next.setHours(next.getHours() + 2); + return next; + }, + + '4h': () => { + const next = new Date(from); + next.setHours(next.getHours() + 4); + return next; + }, + + '8h': () => { + const next = new Date(from); + next.setHours(next.getHours() + 8); + return next; + }, + + '12h': () => { + const next = new Date(from); + next.setHours(next.getHours() + 12); + return next; } - } + }; - static getPriorityEmoji(priority) { - switch (priority) { - case 2: return '🔴'; // high - case 1: return '🟠'; // medium - case 0: return '🟢'; // low - default: return '⚪'; - } - } + const calculator = calculations[frequency]; + return calculator ? calculator() : calculations.daily(); +}; - static async sendSummaryToUser(userId) { - try { - const user = await User.findByPk(userId); - if (!user || !user.telegram_bot_token || !user.telegram_chat_id) { - return false; +// Side effect function to fetch user by ID +const fetchUser = async (userId) => + await User.findByPk(userId); + +// Side effect function to fetch due today tasks +const fetchDueTodayTasks = async (userId, today, tomorrow) => + await Task.findAll({ + where: { + user_id: userId, + due_date: { + [Op.gte]: today, + [Op.lt]: tomorrow + }, + status: { [Op.ne]: 2 } // not done + }, + include: [{ model: Project, attributes: ['name'] }], + order: [['name', 'ASC']] + }); + +// Side effect function to fetch in progress tasks +const fetchInProgressTasks = async (userId) => + await Task.findAll({ + where: { + user_id: userId, + status: 1 // in_progress + }, + include: [{ model: Project, attributes: ['name'] }], + order: [['name', 'ASC']] + }); + +// Side effect function to fetch completed today tasks +const fetchCompletedTodayTasks = async (userId, today, tomorrow) => + await Task.findAll({ + where: { + user_id: userId, + status: 2, // done + updated_at: { + [Op.gte]: today, + [Op.lt]: tomorrow } + }, + include: [{ model: Project, attributes: ['name'] }], + order: [['name', 'ASC']] + }); - const summary = await this.generateSummaryForUser(userId); - if (!summary) return false; +// Side effect function to fetch suggested tasks +const fetchSuggestedTasks = async (userId, excludedIds) => + await Task.findAll({ + where: { + user_id: userId, + status: { [Op.ne]: 2 }, // not done + id: { [Op.notIn]: excludedIds } + }, + include: [{ model: Project, attributes: ['name'] }], + order: [['priority', 'DESC'], ['name', 'ASC']], + limit: 5 + }); - // Send the message via Telegram - const poller = TelegramPoller.getInstance(); - await poller.sendTelegramMessage( - user.telegram_bot_token, - user.telegram_chat_id, - summary - ); +// Side effect function to send telegram message +const sendTelegramMessage = async (token, chatId, message) => { + const poller = TelegramPoller; + return await poller.sendTelegramMessage(token, chatId, message); +}; - // Update the last run time and calculate the next run time - const now = new Date(); - const nextRun = this.calculateNextRunTime(user, now); +// Side effect function to update user tracking fields +const updateUserTracking = async (user, lastRun, nextRun) => + await user.update({ + task_summary_last_run: lastRun, + task_summary_next_run: nextRun + }); - // Update the user's tracking fields - await user.update({ - task_summary_last_run: now, - task_summary_next_run: nextRun - }); +// Function to generate summary for user (contains side effects) +const generateSummaryForUser = async (userId) => { + try { + const user = await fetchUser(userId); + if (!user) return null; - return true; - } catch (error) { - console.error(`Error sending task summary to user ${userId}:`, error.message); + const { today, tomorrow } = createTodayDateRange(); + + // Fetch all task data in parallel + const [dueToday, inProgress, completedToday] = await Promise.all([ + fetchDueTodayTasks(userId, today, tomorrow), + fetchInProgressTasks(userId), + fetchCompletedTodayTasks(userId, today, tomorrow) + ]); + + // Get suggested tasks (excluding already fetched ones) + const excludedIds = [...dueToday.map(t => t.id), ...inProgress.map(t => t.id)]; + const suggestedTasks = await fetchSuggestedTasks(userId, excludedIds); + + // Build task sections + const taskSections = { + dueToday: buildTaskSection(dueToday, "🚀 *Tasks Due Today:*"), + inProgress: buildTaskSection(inProgress, "⚙️ *In Progress Tasks:*"), + suggested: buildTaskSection(suggestedTasks, "💡 *Suggested Tasks:*"), + completed: buildTaskSection(completedToday, "✅ *Completed Today:*", true) + }; + + return buildSummaryMessage(taskSections); + } catch (error) { + console.error('Error generating task summary:', error); + return null; + } +}; + +// Function to send summary to user (contains side effects) +const sendSummaryToUser = async (userId) => { + try { + const user = await fetchUser(userId); + if (!user || !user.telegram_bot_token || !user.telegram_chat_id) { return false; } + + const summary = await generateSummaryForUser(userId); + if (!summary) return false; + + // Send the message via Telegram + await sendTelegramMessage( + user.telegram_bot_token, + user.telegram_chat_id, + summary + ); + + // Update tracking fields + const now = new Date(); + const nextRun = calculateNextRunTime(user, now); + await updateUserTracking(user, now, nextRun); + + return true; + } catch (error) { + console.error(`Error sending task summary to user ${userId}:`, error.message); + return false; } +}; - static calculateNextRunTime(user, fromTime = new Date()) { - const frequency = user.task_summary_frequency; - const from = new Date(fromTime); - - switch (frequency) { - case 'daily': - // Next day at 7 AM - const nextDay = new Date(from); - nextDay.setDate(nextDay.getDate() + 1); - nextDay.setHours(7, 0, 0, 0); - return nextDay; - - case 'weekdays': - // Next weekday at 7 AM - const currentDay = from.getDay(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday - let daysToAdd = 1; - if (currentDay === 5) { // Friday - daysToAdd = 3; // Skip to Monday - } else if (currentDay === 6) { // Saturday - daysToAdd = 2; // Skip to Monday - } - const nextWeekday = new Date(from); - nextWeekday.setDate(nextWeekday.getDate() + daysToAdd); - nextWeekday.setHours(7, 0, 0, 0); - return nextWeekday; - - case 'weekly': - // Next Monday at 7 AM - const nextWeek = new Date(from); - nextWeek.setDate(nextWeek.getDate() + 7); - nextWeek.setHours(7, 0, 0, 0); - return nextWeek; - - case '1h': - return new Date(from.getTime() + 60 * 60 * 1000); - - case '2h': - return new Date(from.getTime() + 2 * 60 * 60 * 1000); - - case '4h': - return new Date(from.getTime() + 4 * 60 * 60 * 1000); - - case '8h': - return new Date(from.getTime() + 8 * 60 * 60 * 1000); - - case '12h': - return new Date(from.getTime() + 12 * 60 * 60 * 1000); - - default: - // Default to daily - const defaultNext = new Date(from); - defaultNext.setDate(defaultNext.getDate() + 1); - defaultNext.setHours(7, 0, 0, 0); - return defaultNext; - } - } -} - -module.exports = TaskSummaryService; \ No newline at end of file +// Export functional interface +module.exports = { + generateSummaryForUser, + sendSummaryToUser, + calculateNextRunTime, + // For testing + _escapeMarkdown: escapeMarkdown, + _getPriorityEmoji: getPriorityEmoji, + _createTodayDateRange: createTodayDateRange, + _formatTaskForDisplay: formatTaskForDisplay, + _buildTaskSection: buildTaskSection, + _buildSummaryMessage: buildSummaryMessage +}; \ No newline at end of file diff --git a/backend/services/telegramPoller.js b/backend/services/telegramPoller.js index 2a73050..5c0ea9a 100644 --- a/backend/services/telegramPoller.js +++ b/backend/services/telegramPoller.js @@ -1,261 +1,369 @@ const https = require('https'); const { User, InboxItem } = require('../models'); -class TelegramPoller { - constructor() { - this.running = false; - this.interval = null; - this.pollInterval = 5000; // 5 seconds - this.usersToPool = []; - this.userStatus = {}; +// Create poller state +const createPollerState = () => ({ + running: false, + interval: null, + pollInterval: 5000, // 5 seconds + usersToPool: [], + userStatus: {} +}); + +// Global mutable state (managed functionally) +let pollerState = createPollerState(); + +// Check if user exists in list +const userExistsInList = (users, userId) => + users.some(u => u.id === userId); + +// Add user to list +const addUserToList = (users, user) => { + if (userExistsInList(users, user.id)) { + return users; } + return [...users, user]; +}; - // Singleton pattern - static getInstance() { - if (!TelegramPoller.instance) { - TelegramPoller.instance = new TelegramPoller(); - } - return TelegramPoller.instance; +// Remove user from list +const removeUserFromList = (users, userId) => + users.filter(u => u.id !== userId); + +// Remove user status +const removeUserStatus = (userStatus, userId) => { + const { [userId]: removed, ...rest } = userStatus; + return rest; +}; + +// Update user status +const updateUserStatus = (userStatus, userId, updates) => ({ + ...userStatus, + [userId]: { + ...userStatus[userId], + ...updates } +}); - // Add user to polling list - async addUser(user) { - if (!user || !user.telegram_bot_token) { - return false; - } +// Get highest update ID from updates +const getHighestUpdateId = (updates) => { + if (!updates.length) return 0; + return Math.max(...updates.map(u => u.update_id)); +}; - // Check if user already in list - const exists = this.usersToPool.find(u => u.id === user.id); - if (!exists) { - this.usersToPool.push(user); - } - - // Start polling if not already running - if (this.usersToPool.length > 0 && !this.running) { - this.startPolling(); - } - - return true; +// Create message parameters +const createMessageParams = (chatId, text, replyToMessageId = null) => { + const params = { chat_id: chatId, text: text }; + if (replyToMessageId) { + params.reply_to_message_id = replyToMessageId; } + return params; +}; - // Remove user from polling list - removeUser(userId) { - this.usersToPool = this.usersToPool.filter(u => u.id !== userId); - delete this.userStatus[userId]; +// Create Telegram API URL +const createTelegramUrl = (token, endpoint, params = {}) => { + const baseUrl = `https://api.telegram.org/bot${token}/${endpoint}`; + if (Object.keys(params).length === 0) return baseUrl; + + const searchParams = new URLSearchParams(params); + return `${baseUrl}?${searchParams}`; +}; - // Stop polling if no users left - if (this.usersToPool.length === 0 && this.running) { - this.stopPolling(); - } - - return true; - } - - // Start the polling process - startPolling() { - if (this.running) return; - - console.log('Starting Telegram polling...'); - this.running = true; - - this.interval = setInterval(async () => { - try { - await this.pollUpdates(); - } catch (error) { - console.error('Error polling Telegram:', error.message); - } - }, this.pollInterval); - } - - // Stop the polling process - stopPolling() { - if (!this.running) return; - - console.log('Stopping Telegram polling...'); - this.running = false; - - if (this.interval) { - clearInterval(this.interval); - this.interval = null; - } - } - - // Poll for updates from Telegram - async pollUpdates() { - for (const user of this.usersToPool) { - const token = user.telegram_bot_token; - if (!token) continue; - - try { - const lastUpdateId = this.userStatus[user.id]?.lastUpdateId || 0; - const updates = await this.getTelegramUpdates(token, lastUpdateId + 1); - - if (updates && updates.length > 0) { - await this.processUpdates(user, updates); - } - } catch (error) { - console.error(`Error getting updates for user ${user.id}:`, error.message); - } - } - } - - // Get updates from Telegram API - getTelegramUpdates(token, offset) { - return new Promise((resolve, reject) => { - const url = `https://api.telegram.org/bot${token}/getUpdates?offset=${offset}&timeout=1`; +// Side effect function to make HTTP GET request +const makeHttpGetRequest = (url, timeout = 5000) => { + return new Promise((resolve, reject) => { + https.get(url, { timeout }, (res) => { + let data = ''; - https.get(url, { timeout: 5000 }, (res) => { - let data = ''; - - res.on('data', (chunk) => { - data += chunk; - }); - - res.on('end', () => { - try { - const response = JSON.parse(data); - if (response.ok && Array.isArray(response.result)) { - resolve(response.result); - } else { - console.error('Telegram API error:', response); - resolve([]); - } - } catch (error) { - reject(error); - } - }); - }).on('error', (error) => { - reject(error); - }).on('timeout', () => { - reject(new Error('Request timeout')); + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + try { + const response = JSON.parse(data); + resolve(response); + } catch (error) { + reject(error); + } + }); + }).on('error', (error) => { + reject(error); + }).on('timeout', () => { + reject(new Error('Request timeout')); + }); + }); +}; + +// Side effect function to make HTTP POST request +const makeHttpPostRequest = (url, postData, options) => { + return new Promise((resolve, reject) => { + const req = https.request(url, options, (res) => { + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => { + try { + const response = JSON.parse(data); + resolve(response); + } catch (error) { + reject(error); + } }); }); - } - // Process updates received from Telegram - async processUpdates(user, updates) { - if (!updates.length) return; + req.on('error', reject); + req.write(postData); + req.end(); + }); +}; - // Track the highest update_id - const highestUpdateId = Math.max(...updates.map(u => u.update_id)); +// Side effect function to get Telegram updates +const getTelegramUpdates = async (token, offset) => { + try { + const url = createTelegramUrl(token, 'getUpdates', { + offset: offset.toString(), + timeout: '1' + }); - // Save the last update ID for this user - if (!this.userStatus[user.id]) { - this.userStatus[user.id] = {}; + const response = await makeHttpGetRequest(url, 5000); + + if (response.ok && Array.isArray(response.result)) { + return response.result; + } else { + console.error('Telegram API error:', response); + return []; } - this.userStatus[user.id].lastUpdateId = highestUpdateId; + } catch (error) { + throw error; + } +}; - for (const update of updates) { - try { - if (update.message && update.message.text) { - await this.processMessage(user, update); - } - } catch (error) { - console.error(`Error processing update ${update.update_id}:`, error.message); +// Side effect function to send Telegram message +const sendTelegramMessage = async (token, chatId, text, replyToMessageId = null) => { + try { + const messageParams = createMessageParams(chatId, text, replyToMessageId); + const postData = JSON.stringify(messageParams); + const url = createTelegramUrl(token, 'sendMessage'); + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData) } - } + }; + + return await makeHttpPostRequest(url, postData, options); + } catch (error) { + throw error; + } +}; + +// Side effect function to update user chat ID +const updateUserChatId = async (userId, chatId) => { + await User.update( + { telegram_chat_id: chatId }, + { where: { id: userId } } + ); +}; + +// Side effect function to create inbox item +const createInboxItem = async (content, userId) => { + return await InboxItem.create({ + content: content, + source: 'telegram', + user_id: userId + }); +}; + +// Function to process a single message (contains side effects) +const processMessage = async (user, update) => { + const message = update.message; + const text = message.text; + const chatId = message.chat.id.toString(); + const messageId = message.message_id; + + console.log(`Processing message from user ${user.id}: ${text}`); + + // Update chat ID if needed + if (!user.telegram_chat_id) { + console.log(`Updating user's telegram_chat_id to ${chatId}`); + await updateUserChatId(user.id, chatId); + user.telegram_chat_id = chatId; // Update local object } - // Process a single message - async processMessage(user, update) { - const message = update.message; - const text = message.text; - const chatId = message.chat.id.toString(); - const messageId = message.message_id; + try { + // Create inbox item + const inboxItem = await createInboxItem(text, user.id); + console.log(`Created inbox item ${inboxItem.id} from Telegram message`); - console.log(`Processing message from user ${user.id}: ${text}`); + // Send confirmation + await sendTelegramMessage( + user.telegram_bot_token, + chatId, + `✅ Added to Tududi inbox: "${text}"`, + messageId + ); + } catch (error) { + console.error('Failed to create inbox item:', error.message); - // Save the chat_id if not already saved - if (!user.telegram_chat_id) { - console.log(`Updating user's telegram_chat_id to ${chatId}`); - await User.update( - { telegram_chat_id: chatId }, - { where: { id: user.id } } - ); - user.telegram_chat_id = chatId; // Update local object + // Send error message + await sendTelegramMessage( + user.telegram_bot_token, + chatId, + `❌ Failed to add to inbox: ${error.message}`, + messageId + ); + } +}; + +// Function to process updates (contains side effects) +const processUpdates = async (user, updates) => { + if (!updates.length) return; + + // Get highest update ID + const highestUpdateId = getHighestUpdateId(updates); + + // Update user status + pollerState = { + ...pollerState, + userStatus: updateUserStatus(pollerState.userStatus, user.id, { + lastUpdateId: highestUpdateId + }) + }; + + // Process each update + for (const update of updates) { + try { + if (update.message && update.message.text) { + await processMessage(user, update); + } + } catch (error) { + console.error(`Error processing update ${update.update_id}:`, error.message); } + } +}; + +// Function to poll updates for all users (contains side effects) +const pollUpdates = async () => { + for (const user of pollerState.usersToPool) { + const token = user.telegram_bot_token; + if (!token) continue; try { - // Create an inbox item - const inboxItem = await InboxItem.create({ - content: text, - source: 'telegram', - user_id: user.id - }); - - console.log(`Created inbox item ${inboxItem.id} from Telegram message`); - - // Send confirmation - await this.sendTelegramMessage( - user.telegram_bot_token, - chatId, - `✅ Added to Tududi inbox: "${text}"`, - messageId - ); + const lastUpdateId = pollerState.userStatus[user.id]?.lastUpdateId || 0; + const updates = await getTelegramUpdates(token, lastUpdateId + 1); + + if (updates && updates.length > 0) { + await processUpdates(user, updates); + } } catch (error) { - console.error('Failed to create inbox item:', error.message); - - // Send error message - await this.sendTelegramMessage( - user.telegram_bot_token, - chatId, - `❌ Failed to add to inbox: ${error.message}`, - messageId - ); + console.error(`Error getting updates for user ${user.id}:`, error.message); } } +}; - // Send a message to Telegram - sendTelegramMessage(token, chatId, text, replyToMessageId = null) { - return new Promise((resolve, reject) => { - const messageParams = { - chat_id: chatId, - text: text - }; +// Function to start polling (contains side effects) +const startPolling = () => { + if (pollerState.running) return; - if (replyToMessageId) { - messageParams.reply_to_message_id = replyToMessageId; - } + console.log('Starting Telegram polling...'); + + const interval = setInterval(async () => { + try { + await pollUpdates(); + } catch (error) { + console.error('Error polling Telegram:', error.message); + } + }, pollerState.pollInterval); - const postData = JSON.stringify(messageParams); - const url = `https://api.telegram.org/bot${token}/sendMessage`; - - const options = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(postData) - } - }; + pollerState = { + ...pollerState, + running: true, + interval + }; +}; - const req = https.request(url, options, (res) => { - let data = ''; - res.on('data', (chunk) => data += chunk); - res.on('end', () => { - try { - const response = JSON.parse(data); - resolve(response); - } catch (error) { - reject(error); - } - }); - }); +// Function to stop polling (contains side effects) +const stopPolling = () => { + if (!pollerState.running) return; - req.on('error', reject); - req.write(postData); - req.end(); - }); + console.log('Stopping Telegram polling...'); + + if (pollerState.interval) { + clearInterval(pollerState.interval); } - // Get status of the poller - getStatus() { - return { - running: this.running, - usersCount: this.usersToPool.length, - pollInterval: this.pollInterval, - userStatus: this.userStatus - }; - } -} + pollerState = { + ...pollerState, + running: false, + interval: null + }; +}; -module.exports = TelegramPoller; \ No newline at end of file +// Function to add user (contains side effects) +const addUser = async (user) => { + if (!user || !user.telegram_bot_token) { + return false; + } + + // Add user to list + const newUsersList = addUserToList(pollerState.usersToPool, user); + + pollerState = { + ...pollerState, + usersToPool: newUsersList + }; + + // Start polling if not already running and we have users + if (pollerState.usersToPool.length > 0 && !pollerState.running) { + startPolling(); + } + + return true; +}; + +// Function to remove user (contains side effects) +const removeUser = (userId) => { + // Remove user from list and status + const newUsersList = removeUserFromList(pollerState.usersToPool, userId); + const newUserStatus = removeUserStatus(pollerState.userStatus, userId); + + pollerState = { + ...pollerState, + usersToPool: newUsersList, + userStatus: newUserStatus + }; + + // Stop polling if no users left + if (pollerState.usersToPool.length === 0 && pollerState.running) { + stopPolling(); + } + + return true; +}; + +// Get poller status +const getStatus = () => ({ + running: pollerState.running, + usersCount: pollerState.usersToPool.length, + pollInterval: pollerState.pollInterval, + userStatus: pollerState.userStatus +}); + +// Export functional interface +module.exports = { + addUser, + removeUser, + startPolling, + stopPolling, + getStatus, + sendTelegramMessage, + // For testing + _createPollerState: createPollerState, + _userExistsInList: userExistsInList, + _addUserToList: addUserToList, + _removeUserFromList: removeUserFromList, + _getHighestUpdateId: getHighestUpdateId, + _createMessageParams: createMessageParams, + _createTelegramUrl: createTelegramUrl +}; \ No newline at end of file