Replace classes with functions

This commit is contained in:
Chris Veleris 2025-06-16 23:01:28 +03:00
parent 3c1209a5a9
commit fd1da2de43
8 changed files with 938 additions and 666 deletions

View file

@ -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

View file

@ -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

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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();
// 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
};

View file

@ -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;
// 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
};

View file

@ -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;
// Export functional interface
module.exports = {
generateSummaryForUser,
sendSummaryToUser,
calculateNextRunTime,
// For testing
_escapeMarkdown: escapeMarkdown,
_getPriorityEmoji: getPriorityEmoji,
_createTodayDateRange: createTodayDateRange,
_formatTaskForDisplay: formatTaskForDisplay,
_buildTaskSection: buildTaskSection,
_buildSummaryMessage: buildSummaryMessage
};

View file

@ -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;
// 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
};